diff --git a/crates/gatewayd/src/mm/rest.rs b/crates/gatewayd/src/mm/rest.rs index 08261b3..3d3d303 100644 --- a/crates/gatewayd/src/mm/rest.rs +++ b/crates/gatewayd/src/mm/rest.rs @@ -30,6 +30,19 @@ struct Channel { id: String, } +#[derive(Deserialize)] +struct User { + username: String, +} + +/// A Mattermost username: 1 to 64 characters of `a-z`, `0-9`, `.`, `-` and `_`. +fn valid_username(name: &str) -> bool { + (1..=64).contains(&name.len()) + && name.bytes().all(|b| { + b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'.' | b'-' | b'_') + }) +} + impl Client { /// `timeout` bounds connecting and each read: no bytes for that long is an error. pub fn new(connector: Connector, token: Secret, timeout: Duration) -> Client { @@ -179,6 +192,42 @@ impl Client { } Ok(channel.id) } + + /// `PUT /api/v4/posts/{post}/patch`: replace the post's text. + pub fn patch_post(&self, post: &str, message: &str) -> Result<(), MmError> { + // A post that is not `valid_id` is Json, and nothing is sent. Otherwise PUT + // /api/v4/posts//patch with {"message": message}, through `call`; the answer is a + // Post, `check`ed and dropped. + if !valid_id(post) { + return Err(MmError::Json( + "a post path must be a Mattermost id".to_string(), + )); + } + let payload = serde_json::json!({ "message": message }).to_string(); + let body = self.call( + "PUT", + &format!("/api/v4/posts/{post}/patch"), + Some(payload.as_bytes()), + )?; + json::(&body)?.check().map(|_| ()) + } + + /// `GET /api/v4/users/{user}`: the user's username. + pub fn username(&self, user: &str) -> Result { + // A user that is not `valid_id` is Json. Otherwise GET /api/v4/users/; a username + // that is not `valid_username` is Json. + if !valid_id(user) { + return Err(MmError::Json( + "a user path must be a Mattermost id".to_string(), + )); + } + let body = self.call("GET", &format!("/api/v4/users/{user}"), None)?; + let user: User = json(&body)?; + if !valid_username(&user.username) { + return Err(MmError::Json("the user has no valid username".to_string())); + } + Ok(user.username) + } } fn status_error(status: u16, body: &[u8]) -> MmError { diff --git a/crates/gatewayd/tests/mm_edit.rs b/crates/gatewayd/tests/mm_edit.rs new file mode 100644 index 0000000..47b72cb --- /dev/null +++ b/crates/gatewayd/tests/mm_edit.rs @@ -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}" + ); + } +} diff --git a/docs/implementer-log.md b/docs/implementer-log.md index 3cfb3aa..80e8a5c 100644 --- a/docs/implementer-log.md +++ b/docs/implementer-log.md @@ -6,6 +6,7 @@ reviewer adds findings under "Reviews" once per milestone. | Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model | |---|---|---|---|---|---|---|---| +| M4b/09-gatewayd-mm-edit | 2026-09-24 | done | 1 | pass | none | Copied `docs/plans/M4b/files/crates/gatewayd/tests/mm_edit.rs` into `crates/gatewayd/tests/`. In `rest.rs`: added the `User { username: String }` struct and `valid_username` (1..=64 bytes of a-z, 0-9, `.`, `-` and `_`) after `Channel`, then `patch_post` (a `post` that is not `valid_id` is `Json` and nothing is sent; otherwise PUT `/api/v4/posts/{post}/patch` with `{"message": message}` through `call`, the answer a `Post` `check`ed and dropped) and `username` (a `user` that is not `valid_id` is `Json`; GET `/api/v4/users/{user}`, a username that is not `valid_username` is `Json`, else the name) at the end of `impl Client`. `cargo fmt --all` then `cargo test -p gatewayd --test mm_edit --test mm_rest` green (4 and 10 passed); `make gate` prints `gate: ok` on the first run. | ? | | M4b/08-gatewayd-mention-commands | 2026-09-24 | done | 1 | pass | none | Copied `sessions_mention.rs` into `crates/gatewayd/tests/`, the new `sessions.rs` (commands now route to `Approval`, the mention stripped) and `serve.rs` (from `serve.rs-task08`, the turn's text is `start`). In `sessions.rs`: added `Route::Approval { thread, command, user, post, at }` after `Queue(Message)`; added the given `ApprovalCommand` enum, `MAX_REASON = proto::MAX_REASON`, and `approval_command` (returns `None` for text that is not a `!` command, else `Approve`/`Deny`/`Malformed`); wrote `strip_mention` (the five steps: strip a leading `@`, take the longest name run, trim a trailing dot, match `me` case-insensitively, return the message when the remainder is empty); in `route` bound `message` from `strip_mention(&post.message, &self.me_name)`, replaced the `!` command block with `approval_command` → Approval then any other `!` → Reply UNKNOWN_COMMAND, and changed the final Queue's `text: message.clone()` to `message.to_string()` since `message` is now `&str`; updated the step comment. In `handle.rs` `handle_post` added the `Route::Approval` arm posting M4B_COMMAND (approvals module wired in task 15) and `M4B_COMMAND` to the `use`. All three suites pass (serve 6, sessions 8, sessions_mention 4); `make gate` prints `gate: ok` on the first run. | ? | | M4b/07-gatewayd-config-approvals | 2026-09-24 | done | 1 | pass | none | Copied `docs/plans/M4b/files/crates/gatewayd/tests/config_approvals.rs` into `crates/gatewayd/tests/`. In `config.rs`: added `#[serde(default)] pub approvals: ApprovalsConfig,` after `pub limits: Limits,` in `Config`, the `ApprovalsConfig { users: Vec, socket: PathBuf }` struct (`#[serde(deny_unknown_fields, default)]`, with the doc comments) after `struct Config`, the `[approvals] users` check in `problem` right before the `[limits] queue` check (each id must be in `allow.users`), and the three methods before `state_path`: `approvers` (`&self.approvals.users`, or `&self.allow.users` when empty), `first_approver` (`.first().map(String::as_str)`), and `gateway_socket` (`self.approvals.socket`, or `/run/gateway/gateway.sock` when `as_os_str().is_empty()`). The `ApprovalsConfig` derives `Default` so the empty `socket` deserialises from an absent field; `PathBuf`'s default is empty, matching the runbook's "empty" rule. `cargo fmt --all` then `cargo test -p gatewayd --test config_approvals --test config` green (4 and 7 passed); `make gate` prints `gate: ok` on the first run. | ? | | M4b/06-brokerd-courier | 2026-09-24 | done | 1 | pass | none | Filled the four `todo!()`s in the copied `courier.rs`: `finished` (`stop.load(SeqCst) || !broker.table().contains(id)`); `pause` (a `while Instant::now() < until && !finished` loop sleeping `timing.tick.min(until.saturating_duration_since(now))`); `frames` (`try_clone` the stream, spawn a reader thread that hands whole frames over an mpsc channel so the courier can `recv_timeout` without losing half a frame, `Ok(rx)`); `answer` (`take` the entry → `Gone {}` if gone, build the `Answer` from `reply.approved`, call `admin::answer`, map the returned `DecisionRecord` through the five-arm match to `Runs`/`Denied`/`Refused`/`Denied`/`Refused`). The four `broker.rs` changes: `RecvTimeoutError` import widened to include `Receiver`, `use crate::courier;` after the `config` use, `let shown = info.clone();` before the insert, step 4 replaced by `courier::alongside(broker, &shown, || wait_for_verdict(...))` matching `Waited`, and the old step 4 moved to `wait_for_verdict` (with the `Waited` enum) before `pub fn alive`. `cargo build -p brokerd` clean; courier test went 1 pass/9 fail then 10 passed three times running; full brokerd suite green; `cargo fmt --all` then `make gate` prints `gate: ok` on the first run. | ? |