Watch
0
0
Fork
You've already forked hyperhive
0

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:
atlas 2026-09-26 19:01:15 +02:00 • committed by mara
commit bfd8189900
5 changed files with 291 additions and 61 deletions

View file

@ -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"});