operator admin sync: cap the grant at one send, split out response verdicts
The admin-room command stays in #admins' history, so a grant tuwunel ignores must not be re-sent every five minutes. The controller now sends `make-user-admin` at most once per operator per process; an operator still missing from the room on a later pass is logged as "make-user-admin for an operator failed to take" and left alone until the controller restarts. The cap lives in the pure `plan()`. swarm-matrix-client's `user_exists` and `joined_members` read their responses through pure `profile_verdict` / `joined_verdict`, the shape `whoami` / `whoami_verdict` already has, with every branch tested: only M_NOT_FOUND is "no account", and a 403 or a list without `joined` is an error rather than an answer. The bridge's handling after authentication moves into `respond()`, which takes the config and write lock instead of the whole AppState, so a test can drive ListOperators through the real request path and see the 403 without an HTTP client or an authelia. docs/integrations/matrix.md no longer says @swarm is the only homeserver admin.
This commit is contained in:
parent
9e7fdb55ae
commit
cf88fbba9d
4 changed files with 261 additions and 42 deletions
|
|
@ -244,16 +244,21 @@ pub async fn joined_members(
|
|||
)
|
||||
.await
|
||||
.with_context(|| format!("GET /rooms/{room_id}/joined_members"))?;
|
||||
joined_verdict(status, &json).with_context(|| format!("the members of {room_id}"))
|
||||
}
|
||||
|
||||
/// [`joined_members`]'s reading of a response, apart from the IO.
|
||||
fn joined_verdict(
|
||||
status: reqwest::StatusCode,
|
||||
json: &serde_json::Value,
|
||||
) -> Result<std::collections::BTreeSet<String>> {
|
||||
if !status.is_success() {
|
||||
bail!(
|
||||
"the homeserver refused to list {room_id}'s members: {}",
|
||||
why(status, &json)
|
||||
);
|
||||
bail!("the homeserver refused to list them: {}", why(status, json));
|
||||
}
|
||||
json["joined"]
|
||||
.as_object()
|
||||
.map(|joined| joined.keys().cloned().collect())
|
||||
.with_context(|| format!("the homeserver's member list for {room_id} carried no `joined`"))
|
||||
.context("the homeserver's member list carried no `joined`")
|
||||
}
|
||||
|
||||
/// Whether `user_id` has an account on this homeserver.
|
||||
|
|
@ -277,16 +282,22 @@ pub async fn user_exists(
|
|||
)
|
||||
.await
|
||||
.with_context(|| format!("GET /profile/{user_id}"))?;
|
||||
profile_verdict(status, &json).with_context(|| format!("{user_id}'s profile"))
|
||||
}
|
||||
|
||||
/// [`user_exists`]'s reading of a response, apart from the IO.
|
||||
///
|
||||
/// Only `M_NOT_FOUND` means "no account". Any other refusal says nothing
|
||||
/// about the account, so it is an error rather than a `false` a caller would
|
||||
/// act on.
|
||||
fn profile_verdict(status: reqwest::StatusCode, json: &serde_json::Value) -> Result<bool> {
|
||||
if status.is_success() {
|
||||
return Ok(true);
|
||||
}
|
||||
if errcode(&json) == Some("M_NOT_FOUND") {
|
||||
if errcode(json) == Some("M_NOT_FOUND") {
|
||||
return Ok(false);
|
||||
}
|
||||
bail!(
|
||||
"the homeserver refused {user_id}'s profile: {}",
|
||||
why(status, &json)
|
||||
)
|
||||
bail!("the homeserver refused: {}", why(status, json))
|
||||
}
|
||||
|
||||
/// Send `body` to `room_id` as an `m.text` message, and return its event id.
|
||||
|
|
@ -539,6 +550,62 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_profile_answer_means_the_account_exists() {
|
||||
let json = serde_json::json!({ "displayname": "mara" });
|
||||
assert!(profile_verdict(reqwest::StatusCode::OK, &json).expect("a verdict"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_found_is_no_account_and_not_an_error() {
|
||||
let json =
|
||||
serde_json::json!({ "errcode": "M_NOT_FOUND", "error": "Profile was not found." });
|
||||
assert!(!profile_verdict(reqwest::StatusCode::NOT_FOUND, &json).expect("a verdict"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn any_other_profile_refusal_is_an_error_and_not_no_account() {
|
||||
// The control for the arm above: a 403 says nothing about whether the
|
||||
// account exists, and reading it as "no account" would skip a grant.
|
||||
for (status, json) in [
|
||||
(
|
||||
reqwest::StatusCode::FORBIDDEN,
|
||||
serde_json::json!({ "errcode": "M_FORBIDDEN" }),
|
||||
),
|
||||
(reqwest::StatusCode::NOT_FOUND, serde_json::json!({})),
|
||||
(reqwest::StatusCode::BAD_GATEWAY, serde_json::json!({})),
|
||||
] {
|
||||
assert!(profile_verdict(status, &json).is_err(), "{status} {json}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_joined_members_are_the_keys_of_joined() {
|
||||
let json = serde_json::json!({ "joined": {
|
||||
"@swarm:t.local": { "display_name": "swarm" },
|
||||
"@mara:t.local": {},
|
||||
}});
|
||||
let members = joined_verdict(reqwest::StatusCode::OK, &json).expect("a member list");
|
||||
assert_eq!(
|
||||
members.into_iter().collect::<Vec<_>>(),
|
||||
["@mara:t.local", "@swarm:t.local"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_member_list_without_joined_or_a_refusal_is_an_error() {
|
||||
// Never an empty set: that would read as "nobody is admin yet" and
|
||||
// send a grant for every operator.
|
||||
assert!(joined_verdict(reqwest::StatusCode::OK, &serde_json::json!({})).is_err());
|
||||
assert!(
|
||||
joined_verdict(
|
||||
reqwest::StatusCode::FORBIDDEN,
|
||||
&serde_json::json!({ "errcode": "M_FORBIDDEN" })
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_path_segment_escapes_what_would_end_or_split_the_path() {
|
||||
assert_eq!(path_segment("#admins:t.local"), "%23admins%3At.local");
|
||||
|
|
|
|||
Loading…
Reference in a new issue