gatewayd: mm, Mattermost's events and REST calls, typed
Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
//! Mattermost's JSON: events as the server sends them (v11.11.0 shapes), the typing request, and
|
||||
//! the `posts?since` list, whose edited, deleted and root-only posts must not be answered (M4a spec,
|
||||
//! sections 7 and 9). Do not edit.
|
||||
|
||||
use gatewayd::mm::{Event, MmError, SINCE_LIMIT, parse_event, since_list, typing};
|
||||
use serde_json::json;
|
||||
|
||||
const KYLE: &str = "k0000000000000000000000000";
|
||||
const DM: &str = "d0000000000000000000000000";
|
||||
|
||||
fn post(id: &str, create_at: i64) -> serde_json::Value {
|
||||
json!({
|
||||
"id": id, "create_at": create_at, "update_at": create_at, "edit_at": 0, "delete_at": 0,
|
||||
"is_pinned": false, "user_id": KYLE, "channel_id": DM, "root_id": "", "original_id": "",
|
||||
"message": "hello", "type": "", "props": {"from_bot": "true"}, "hashtags": "",
|
||||
"pending_post_id": "", "reply_count": 0, "metadata": {}
|
||||
})
|
||||
}
|
||||
|
||||
fn id(n: u32) -> String {
|
||||
format!("p{n:025}")
|
||||
}
|
||||
|
||||
fn posted(post: &serde_json::Value, channel_type: &str) -> String {
|
||||
json!({
|
||||
"event": "posted",
|
||||
"data": {
|
||||
"channel_display_name": "@kyle", "channel_name": "x__y", "channel_type": channel_type,
|
||||
"post": post.to_string(), "sender_name": "@kyle", "set_online": true, "team_id": ""
|
||||
},
|
||||
"broadcast": {"omit_users": null, "user_id": "", "channel_id": DM, "team_id": ""},
|
||||
"seq": 3
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hello_replies_and_other_events() {
|
||||
let hello =
|
||||
json!({"event": "hello", "data": {"server_version": "11.11.0"}, "broadcast": {}, "seq": 0});
|
||||
assert_eq!(parse_event(&hello.to_string()).unwrap(), Event::Hello);
|
||||
assert_eq!(
|
||||
parse_event(r#"{"status":"OK","seq_reply":1}"#).unwrap(),
|
||||
Event::Other(String::new())
|
||||
);
|
||||
let typing = json!({"event": "typing", "data": {"parent_id": ""}, "broadcast": {}, "seq": 4});
|
||||
assert_eq!(
|
||||
parse_event(&typing.to_string()).unwrap(),
|
||||
Event::Other("typing".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_posted_event_carries_the_post_as_a_string() {
|
||||
let mut p = post(&id(1), 1_758_650_000_123);
|
||||
p["root_id"] = json!(id(0));
|
||||
p["message"] = json!("line one\nline two");
|
||||
let Event::Posted { post, channel_type } = parse_event(&posted(&p, "D")).unwrap() else {
|
||||
panic!("not a post")
|
||||
};
|
||||
assert_eq!(channel_type, "D");
|
||||
assert_eq!(
|
||||
(post.id.as_str(), post.root_id.as_str()),
|
||||
(id(1).as_str(), id(0).as_str())
|
||||
);
|
||||
assert_eq!(
|
||||
(post.user_id.as_str(), post.channel_id.as_str()),
|
||||
(KYLE, DM)
|
||||
);
|
||||
assert_eq!(
|
||||
(post.message.as_str(), post.create_at, post.kind.as_str()),
|
||||
("line one\nline two", 1_758_650_000_123, "")
|
||||
);
|
||||
let mut s = self::post(&id(2), 5);
|
||||
s["type"] = json!("system_join_channel");
|
||||
let Event::Posted { post, .. } = parse_event(&posted(&s, "O")).unwrap() else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(post.kind, "system_join_channel");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_posts_are_errors_not_panics() {
|
||||
let p = post(&id(1), 5);
|
||||
let as_object =
|
||||
json!({"event": "posted", "data": {"post": p, "channel_type": "D"}}).to_string();
|
||||
let mut cases = vec![
|
||||
as_object,
|
||||
"not json".to_string(),
|
||||
"[1,2]".to_string(),
|
||||
"{\"event\":5}".to_string(),
|
||||
];
|
||||
for (field, value) in [
|
||||
("id", json!("../../etc")),
|
||||
("user_id", json!("")),
|
||||
("channel_id", json!("A0000000000000000000000000")),
|
||||
("root_id", json!("short")),
|
||||
("create_at", json!("soon")),
|
||||
] {
|
||||
let mut bad = post(&id(1), 5);
|
||||
bad[field] = value;
|
||||
cases.push(posted(&bad, "D"));
|
||||
}
|
||||
let mut missing = post(&id(1), 5);
|
||||
missing.as_object_mut().unwrap().remove("create_at");
|
||||
cases.push(posted(&missing, "D"));
|
||||
for case in cases {
|
||||
assert!(
|
||||
matches!(parse_event(&case), Err(MmError::Json(_))),
|
||||
"{case}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_typing_request() {
|
||||
let got: serde_json::Value = serde_json::from_str(&typing(7, DM, &id(0))).unwrap();
|
||||
assert_eq!(
|
||||
got,
|
||||
json!({"action": "user_typing", "seq": 7, "data": {"channel_id": DM, "parent_id": id(0)}})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn since_keeps_new_posts_in_order_and_nothing_else() {
|
||||
let since = 1000;
|
||||
let mut edited = post(&id(1), 900);
|
||||
edited["update_at"] = json!(1500);
|
||||
let mut deleted = post(&id(2), 1100);
|
||||
deleted["delete_at"] = json!(1200);
|
||||
let root_only = post(&id(3), 10);
|
||||
let later = post(&id(4), 1300);
|
||||
let earlier = post(&id(5), 1200);
|
||||
let at_since = post(&id(6), 1000);
|
||||
let mut posts = serde_json::Map::new();
|
||||
for p in [&edited, &deleted, &root_only, &later, &earlier, &at_since] {
|
||||
posts.insert(p["id"].as_str().unwrap().to_string(), p.clone());
|
||||
}
|
||||
let order = json!([id(4), id(2), id(1), id(5), id(9), id(6)]);
|
||||
let body = json!({"order": order, "posts": posts, "next_post_id": "", "prev_post_id": "", "has_next": false});
|
||||
let got = since_list(body.to_string().as_bytes(), since).unwrap();
|
||||
let ids: Vec<&str> = got.posts.iter().map(|p| p.id.as_str()).collect();
|
||||
assert_eq!(ids, [id(5), id(4)]);
|
||||
assert!(!got.full);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_since_answer_says_so() {
|
||||
let mut posts = serde_json::Map::new();
|
||||
let mut order = Vec::new();
|
||||
for n in 0..SINCE_LIMIT {
|
||||
let n = u32::try_from(n).unwrap();
|
||||
posts.insert(id(n), post(&id(n), 2000 + i64::from(n)));
|
||||
order.push(id(n));
|
||||
}
|
||||
let body = json!({"order": order, "posts": posts});
|
||||
let got = since_list(body.to_string().as_bytes(), 1000).unwrap();
|
||||
assert!(got.full);
|
||||
assert_eq!(got.posts.len(), SINCE_LIMIT);
|
||||
let empty = since_list(br#"{"order":[],"posts":{}}"#, 0).unwrap();
|
||||
assert!(empty.posts.is_empty() && !empty.full);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_error_body_is_quoted_in_messages() {
|
||||
let e = MmError::Status(500, "boom\ngatewayd: a forged line".to_string());
|
||||
let text = e.to_string();
|
||||
assert!(!text.contains('\n'), "{text}");
|
||||
assert_eq!(
|
||||
MmError::Auth(401).to_string(),
|
||||
"Mattermost refused the token (401)"
|
||||
);
|
||||
}
|
||||
@@ -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 gatewayd::config::ServerUrl;
|
||||
use gatewayd::mm::MmError;
|
||||
use gatewayd::mm::rest::Client;
|
||||
use gatewayd::net::Connector;
|
||||
use gatewayd::secrets::Secret;
|
||||
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,93 @@
|
||||
//! A scripted HTTP server for tests: it records each request and answers with what the test's
|
||||
//! function returns for it. Built on `tls_server`. Do not edit.
|
||||
|
||||
#![allow(dead_code)] // each test file uses a different part of this module
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex, mpsc};
|
||||
|
||||
use rustls::ServerConfig;
|
||||
|
||||
use crate::tls_server::serve;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Request {
|
||||
pub method: String,
|
||||
pub path: String,
|
||||
/// The whole head, for tests that look for a header.
|
||||
pub head: String,
|
||||
pub body: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Request {
|
||||
pub fn json(&self) -> serde_json::Value {
|
||||
serde_json::from_slice(&self.body).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
/// A response with a JSON body.
|
||||
pub fn reply(status: u16, extra_headers: &str, body: &str) -> String {
|
||||
format!(
|
||||
"HTTP/1.1 {status} X\r\nContent-Type: application/json\r\n{extra_headers}Content-Length: {}\r\n\r\n{body}",
|
||||
body.len()
|
||||
)
|
||||
}
|
||||
|
||||
fn read_request(conn: &mut dyn Read) -> Option<Request> {
|
||||
let mut head = Vec::new();
|
||||
let mut byte = [0u8; 1];
|
||||
while !head.ends_with(b"\r\n\r\n") {
|
||||
if conn.read(&mut byte).ok()? == 0 {
|
||||
return None;
|
||||
}
|
||||
head.push(byte[0]);
|
||||
}
|
||||
let head = String::from_utf8(head).ok()?;
|
||||
let mut first = head.split_whitespace();
|
||||
let method = first.next()?.to_string();
|
||||
let path = first.next()?.to_string();
|
||||
let length = head
|
||||
.lines()
|
||||
.find_map(|l| {
|
||||
l.to_ascii_lowercase()
|
||||
.strip_prefix("content-length:")
|
||||
.map(|v| v.trim().parse().ok())
|
||||
})
|
||||
.flatten()
|
||||
.unwrap_or(0);
|
||||
let mut body = vec![0u8; length];
|
||||
conn.read_exact(&mut body).ok()?;
|
||||
Some(Request {
|
||||
method,
|
||||
path,
|
||||
head,
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
/// Serve requests: `answer(n, &request)` gives the response to the n-th request (from 0). Every
|
||||
/// request is sent on the returned channel.
|
||||
pub fn serve_http<F>(
|
||||
tls: Option<Arc<ServerConfig>>,
|
||||
answer: F,
|
||||
) -> (SocketAddr, mpsc::Receiver<Request>)
|
||||
where
|
||||
F: Fn(usize, &Request) -> String + Send + Sync + 'static,
|
||||
{
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let tx = Mutex::new(tx);
|
||||
let count = AtomicUsize::new(0);
|
||||
let addr = serve(tls, move |mut conn| {
|
||||
let Some(request) = read_request(&mut conn) else {
|
||||
return;
|
||||
};
|
||||
let n = count.fetch_add(1, Ordering::SeqCst);
|
||||
let response = answer(n, &request);
|
||||
let _ = tx.lock().unwrap().send(request);
|
||||
let _ = conn.write_all(response.as_bytes());
|
||||
let _ = conn.flush();
|
||||
});
|
||||
(addr, rx)
|
||||
}
|
||||
Reference in New Issue
Block a user