gatewayd: mm, patch_post and username
Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
@@ -30,6 +30,19 @@ struct Channel {
|
|||||||
id: String,
|
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 {
|
impl Client {
|
||||||
/// `timeout` bounds connecting and each read: no bytes for that long is an error.
|
/// `timeout` bounds connecting and each read: no bytes for that long is an error.
|
||||||
pub fn new(connector: Connector, token: Secret, timeout: Duration) -> Client {
|
pub fn new(connector: Connector, token: Secret, timeout: Duration) -> Client {
|
||||||
@@ -179,6 +192,42 @@ impl Client {
|
|||||||
}
|
}
|
||||||
Ok(channel.id)
|
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/<post>/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::<Post>(&body)?.check().map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/v4/users/{user}`: the user's username.
|
||||||
|
pub fn username(&self, user: &str) -> Result<String, MmError> {
|
||||||
|
// A user that is not `valid_id` is Json. Otherwise GET /api/v4/users/<user>; 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 {
|
fn status_error(status: u16, body: &[u8]) -> MmError {
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ reviewer adds findings under "Reviews" once per milestone.
|
|||||||
|
|
||||||
| Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model |
|
| 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/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<String>, 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 `<home>/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/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<String>, 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 `<home>/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. | ? |
|
| 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. | ? |
|
||||||
|
|||||||
Reference in New Issue
Block a user