hive-c0re: stop reporting refused invites as success; re-register the config-PR hook when its secret changes
invite_user_id mapped every 403 M_FORBIDDEN to Ok(()). The membership pre-check already skips invited/joined users, so the 403s that reach the POST are mostly real refusals (banned target, sender without power), including `hivectl matrix invite`. A 403 is now success only when a membership re-read shows the user invited or joined; otherwise it is an error carrying the status and body. admin_room_send_and_poll read the send response's event_id with unwrap_or_default() and, when it was missing, walked every recent event unanchored, so an older bot reply (an earlier reset password) could be returned as this command's result. A send response without an event_id is now an error. run_destroy_bookkeeping discarded fail_pending_for_agent's error; it now warns like its neighbouring steps. ensure_config_pr_webhook returned as soon as a hook with the target URL existed, so a regenerated webhook-secret never reached Forgejo and every config-PR delivery failed HMAC until the 5-minute poll caught up. Forgejo's edit-hook API ignores `secret` and never returns it, so the SHA-256 of the secret last registered is recorded at forge/config-pr-webhook-secret-sha256; when it doesn't match, the same-URL hook is deleted and recreated. The paths.rs doc claiming re-registration on change now describes this. Refs #4723
This commit is contained in:
parent
0f58cdbde2
commit
bfd8189900
5 changed files with 291 additions and 61 deletions
|
|
@ -518,6 +518,28 @@ mod extract_new_password_tests {
|
|||
}
|
||||
}
|
||||
|
||||
/// The `event_id` of an admin-room command, from its `PUT .../send`
|
||||
/// response. It is the anchor separating the bot's reply to this command
|
||||
/// from older replies in the room, so a response without one is an error:
|
||||
/// unanchored, an earlier reply (an older reset password) would be returned
|
||||
/// as this command's result.
|
||||
async fn sent_event_id(resp: reqwest::Response) -> Result<String> {
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let body = resp.json::<serde_json::Value>().await.unwrap_or_default();
|
||||
anyhow::bail!("matrix: admin room send failed: HTTP {status}, body: {body}");
|
||||
}
|
||||
let body = resp
|
||||
.json::<serde_json::Value>()
|
||||
.await
|
||||
.context("matrix: parse admin room send response")?;
|
||||
body["event_id"]
|
||||
.as_str()
|
||||
.filter(|id| !id.is_empty())
|
||||
.map(str::to_owned)
|
||||
.with_context(|| format!("matrix: admin room send response has no event_id: {body}"))
|
||||
}
|
||||
|
||||
/// Send a command to the Matrix admin room and poll for a bot response.
|
||||
///
|
||||
/// Strategy: send the command, capture its `event_id`, then poll backwards
|
||||
|
|
@ -552,18 +574,7 @@ async fn admin_room_send_and_poll<T>(
|
|||
.send()
|
||||
.await
|
||||
.context("matrix: PUT admin room message")?;
|
||||
if !send_resp.status().is_success() {
|
||||
let body = send_resp
|
||||
.json::<serde_json::Value>()
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
anyhow::bail!("matrix: admin room send failed: {body}");
|
||||
}
|
||||
let send_json = send_resp
|
||||
.json::<serde_json::Value>()
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let our_event_id = send_json["event_id"].as_str().unwrap_or("").to_owned();
|
||||
let our_event_id = sent_event_id(send_resp).await?;
|
||||
|
||||
// Poll for bot response: fetch the 20 most recent events (newest-first)
|
||||
// on each tick. Walk the list until we hit our own command event_id;
|
||||
|
|
@ -586,12 +597,7 @@ async fn admin_room_send_and_poll<T>(
|
|||
for event in events {
|
||||
// Stop as soon as we reach our own command — everything
|
||||
// older (further into the list) predates our request.
|
||||
// If our_event_id is empty (malformed PUT response), we skip
|
||||
// this guard and inspect all 20 events — slight risk of a
|
||||
// false match from an older response, but an acceptable fallback.
|
||||
if !our_event_id.is_empty()
|
||||
&& event["event_id"].as_str() == Some(our_event_id.as_str())
|
||||
{
|
||||
if event["event_id"].as_str() == Some(our_event_id.as_str()) {
|
||||
break;
|
||||
}
|
||||
if event["type"].as_str() != Some("m.room.message") {
|
||||
|
|
@ -1505,8 +1511,8 @@ async fn room_membership(
|
|||
/// Invite a fully-qualified Matrix user id (`@user:server`) to `room_id`
|
||||
/// using the sender token. Idempotent: a user who is already a member or
|
||||
/// already has a pending invite is left untouched (no fresh invite is sent,
|
||||
/// so they are not re-notified), and a 403 `M_FORBIDDEN` / `M_BAD_STATE`
|
||||
/// from a racing invite is still treated as success.
|
||||
/// so they are not re-notified). A refused invite is an error unless the
|
||||
/// user turns out to be invited or joined anyway — see [`refused_invite`].
|
||||
async fn invite_user_id(
|
||||
client: &reqwest::Client,
|
||||
sender_token: &str,
|
||||
|
|
@ -1541,16 +1547,31 @@ async fn invite_user_id(
|
|||
tracing::debug!(%user_id, %room_id, "matrix: invited to room");
|
||||
return Ok(());
|
||||
}
|
||||
// 403 with M_FORBIDDEN or M_BAD_STATE typically means the user is
|
||||
// already a member or has a pending invite — both are fine.
|
||||
if status == StatusCode::FORBIDDEN {
|
||||
let body = resp.json::<serde_json::Value>().await.unwrap_or_default();
|
||||
let errcode = body["errcode"].as_str().unwrap_or("");
|
||||
if errcode == "M_FORBIDDEN" || errcode == "M_BAD_STATE" {
|
||||
tracing::debug!(%user_id, %room_id, %errcode, "matrix: invite skipped (already member/invited)");
|
||||
return Ok(());
|
||||
}
|
||||
anyhow::bail!("matrix: invite {user_id} to {room_id}: HTTP {status}, body: {body}");
|
||||
// The pre-check misses a member when its read failed or a concurrent
|
||||
// invite landed after it, so a refusal re-reads the membership.
|
||||
let membership = if status == StatusCode::FORBIDDEN {
|
||||
room_membership(client, sender_token, &encoded_room_id, user_id).await
|
||||
} else {
|
||||
None
|
||||
};
|
||||
refused_invite(resp, membership.as_deref(), user_id, room_id).await
|
||||
}
|
||||
|
||||
/// Outcome of an invite POST the homeserver did not accept. A 403 is success
|
||||
/// only when `membership`, re-read after the refusal, shows the user invited
|
||||
/// or joined: `M_FORBIDDEN` covers both "already in the room" and real
|
||||
/// refusals (banned target, sender lacks power), so the errcode can't tell
|
||||
/// them apart.
|
||||
async fn refused_invite(
|
||||
resp: reqwest::Response,
|
||||
membership: Option<&str>,
|
||||
user_id: &str,
|
||||
room_id: &str,
|
||||
) -> Result<()> {
|
||||
let status = resp.status();
|
||||
if status == StatusCode::FORBIDDEN && matches!(membership, Some("invite" | "join")) {
|
||||
tracing::debug!(%user_id, %room_id, "matrix: invite refused, user already member/invited");
|
||||
return Ok(());
|
||||
}
|
||||
let body = resp.json::<serde_json::Value>().await.unwrap_or_default();
|
||||
anyhow::bail!("matrix: invite {user_id} to {room_id}: HTTP {status}, body: {body}")
|
||||
|
|
@ -1846,6 +1867,75 @@ mod tests {
|
|||
assert_eq!(extract_access_token(&body).unwrap(), "syt_abc123");
|
||||
}
|
||||
|
||||
/// A homeserver response built in memory, so no client (and no TLS
|
||||
/// roots) is needed to drive the response-handling halves.
|
||||
fn response(status: u16, body: &'static str) -> reqwest::Response {
|
||||
axum::http::Response::builder()
|
||||
.status(status)
|
||||
.body(body)
|
||||
.expect("valid mock response")
|
||||
.into()
|
||||
}
|
||||
|
||||
const FORBIDDEN_BODY: &str = r#"{"errcode":"M_FORBIDDEN","error":"refused"}"#;
|
||||
|
||||
/// A banned target or a sender without power gets exactly this 403;
|
||||
/// with no membership behind it, the invite did not happen.
|
||||
#[tokio::test]
|
||||
async fn a_refused_invite_without_membership_is_an_error() {
|
||||
for membership in [None, Some("ban"), Some("leave")] {
|
||||
let outcome =
|
||||
refused_invite(response(403, FORBIDDEN_BODY), membership, "@a:x", "!r:x").await;
|
||||
assert!(outcome.is_err(), "membership {membership:?} must not pass");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_refused_invite_for_a_member_is_success() {
|
||||
for membership in ["invite", "join"] {
|
||||
refused_invite(
|
||||
response(403, FORBIDDEN_BODY),
|
||||
Some(membership),
|
||||
"@a:x",
|
||||
"!r:x",
|
||||
)
|
||||
.await
|
||||
.expect("already invited/joined is the goal state");
|
||||
}
|
||||
}
|
||||
|
||||
/// Membership only excuses a 403; any other failure stays a failure.
|
||||
#[tokio::test]
|
||||
async fn a_non_403_invite_failure_or_garbage_body_is_an_error() {
|
||||
for (status, body) in [(500, "not json"), (403, "not json"), (429, "{}")] {
|
||||
let outcome = refused_invite(response(status, body), None, "@a:x", "!r:x").await;
|
||||
assert!(outcome.is_err(), "HTTP {status} {body:?} must fail");
|
||||
}
|
||||
let outcome = refused_invite(response(500, "{}"), Some("join"), "@a:x", "!r:x").await;
|
||||
assert!(outcome.is_err(), "a 500 is not excused by membership");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_admin_send_without_an_event_id_is_an_error() {
|
||||
for (status, body) in [
|
||||
(500, r#"{"event_id":"$e"}"#),
|
||||
(200, "not json"),
|
||||
(200, "{}"),
|
||||
(200, r#"{"event_id":""}"#),
|
||||
] {
|
||||
let outcome = sent_event_id(response(status, body)).await;
|
||||
assert!(outcome.is_err(), "HTTP {status} {body:?} must fail");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_admin_send_returns_its_event_id() {
|
||||
let id = sent_event_id(response(200, r#"{"event_id":"$e"}"#))
|
||||
.await
|
||||
.expect("well-formed send response");
|
||||
assert_eq!(id, "$e");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_access_token_errors_on_missing_field() {
|
||||
let body = serde_json::json!({"user_id": "@alice:matrix.example.org"});
|
||||
|
|
|
|||
Loading…
Reference in a new issue