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
|
|
@ -184,6 +184,10 @@ lands on.
|
||||||
- **No account, no grant.** A member who has never logged in to matrix
|
- **No account, no grant.** A member who has never logged in to matrix
|
||||||
has no account to promote. The first pass after their first SSO login
|
has no account to promote. The first pass after their first SSO login
|
||||||
promotes them.
|
promotes them.
|
||||||
|
- **One command per member per controller run.** A member still missing
|
||||||
|
from `#admins` after it's sent gets a `make-user-admin … failed to take`
|
||||||
|
warning in the controller log on each later pass, and no second command
|
||||||
|
until the controller restarts.
|
||||||
- Runs only on a controller with both
|
- Runs only on a controller with both
|
||||||
`services.hyperhive.deploy.swarm-controller.authBridgeUrl` and
|
`services.hyperhive.deploy.swarm-controller.authBridgeUrl` and
|
||||||
`services.hyperhive.deploy.swarm-controller.matrixHomeserverUrl` set.
|
`services.hyperhive.deploy.swarm-controller.matrixHomeserverUrl` set.
|
||||||
|
|
@ -222,8 +226,10 @@ tuwunel has none.
|
||||||
Promoting a user to homeserver admin and resetting a password both need an
|
Promoting a user to homeserver admin and resetting a password both need an
|
||||||
admin **sender**: `!admin …` messages into `#admins:<server_name>`, and
|
admin **sender**: `!admin …` messages into `#admins:<server_name>`, and
|
||||||
tuwunel only treats a message as a command when its sender is already an
|
tuwunel only treats a message as a command when its sender is already an
|
||||||
admin. Only `@swarm` is a homeserver admin, so both are swarm-level
|
admin. Of the swarm's own accounts only `@swarm` is a homeserver admin
|
||||||
operations.
|
(operators are too, see
|
||||||
|
[Operators are homeserver admins](#operators-are-homeserver-admins)), so
|
||||||
|
both are swarm-level operations.
|
||||||
|
|
||||||
<details><summary>Store precedence for a hive's sender account</summary>
|
<details><summary>Store precedence for a hive's sender account</summary>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -150,11 +150,19 @@ async fn handle_request(
|
||||||
Ok(caller) => caller,
|
Ok(caller) => caller,
|
||||||
Err(resp) => return resp,
|
Err(resp) => return resp,
|
||||||
};
|
};
|
||||||
if let Err(refusal) = permit(
|
respond(&state.config, &state.write_lock, caller.as_deref(), req).await
|
||||||
&req,
|
}
|
||||||
caller.as_deref(),
|
|
||||||
&state.config.operator_reader_client_id,
|
/// Everything after authentication: the per-request [`permit`] check, then
|
||||||
) {
|
/// the operation. Takes the config and the write lock rather than
|
||||||
|
/// [`AppState`], whose HTTP client only [`authorize`] uses.
|
||||||
|
async fn respond(
|
||||||
|
config: &Config,
|
||||||
|
write_lock: &tokio::sync::Mutex<()>,
|
||||||
|
caller: Option<&str>,
|
||||||
|
req: BridgeRequest,
|
||||||
|
) -> Response {
|
||||||
|
if let Err(refusal) = permit(&req, caller, &config.operator_reader_client_id) {
|
||||||
// The client id is not a credential; naming it is what lets an
|
// The client id is not a credential; naming it is what lets an
|
||||||
// operator see which client asked for something it may not have.
|
// operator see which client asked for something it may not have.
|
||||||
tracing::warn!(client_id = ?caller, request = ?req, "refused a request scoped to another client");
|
tracing::warn!(client_id = ?caller, request = ?req, "refused a request scoped to another client");
|
||||||
|
|
@ -162,10 +170,10 @@ async fn handle_request(
|
||||||
}
|
}
|
||||||
let (op, result) = match req {
|
let (op, result) = match req {
|
||||||
BridgeRequest::EnsureAgentIdentity { name } => {
|
BridgeRequest::EnsureAgentIdentity { name } => {
|
||||||
("ensure_identity", handle(&state, name).await)
|
("ensure_identity", handle(config, write_lock, name).await)
|
||||||
}
|
}
|
||||||
BridgeRequest::ListAgentIdentities => ("list_identities", list(&state)),
|
BridgeRequest::ListAgentIdentities => ("list_identities", list(config)),
|
||||||
BridgeRequest::ListOperators => ("list_operators", list_operators(&state)),
|
BridgeRequest::ListOperators => ("list_operators", list_operators(config)),
|
||||||
};
|
};
|
||||||
match result {
|
match result {
|
||||||
Ok(body) => (StatusCode::OK, Json(body)).into_response(),
|
Ok(body) => (StatusCode::OK, Json(body)).into_response(),
|
||||||
|
|
@ -304,20 +312,23 @@ fn refuse(refusal: Refusal) -> Response {
|
||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle(state: &AppState, name: String) -> Result<BridgeResponse> {
|
async fn handle(
|
||||||
|
cfg: &Config,
|
||||||
|
write_lock: &tokio::sync::Mutex<()>,
|
||||||
|
name: String,
|
||||||
|
) -> Result<BridgeResponse> {
|
||||||
store::validate_username(&name)?;
|
store::validate_username(&name)?;
|
||||||
// After the charset check and before anything touches the store: a
|
// After the charset check and before anything touches the store: a
|
||||||
// reserved name is refused on its own terms, not as a side effect of a
|
// reserved name is refused on its own terms, not as a side effect of a
|
||||||
// collision with whatever happens to be in the file today.
|
// collision with whatever happens to be in the file today.
|
||||||
store::reject_reserved_name(&name)?;
|
store::reject_reserved_name(&name)?;
|
||||||
let cfg = &state.config;
|
|
||||||
|
|
||||||
// Held across the whole load → insert → publish sequence, not just the
|
// Held across the whole load → insert → publish sequence, not just the
|
||||||
// write — two concurrent `EnsureAgentIdentity` calls must not both read
|
// write — two concurrent `EnsureAgentIdentity` calls must not both read
|
||||||
// the same on-disk snapshot before either publishes. See the field's
|
// the same on-disk snapshot before either publishes. See the field's
|
||||||
// doc comment on `AppState::write_lock` for why this is reachable in
|
// doc comment on `AppState::write_lock` for why this is reachable in
|
||||||
// practice, not just theoretically.
|
// practice, not just theoretically.
|
||||||
let _write_guard = state.write_lock.lock().await;
|
let _write_guard = write_lock.lock().await;
|
||||||
|
|
||||||
let mut user_store = store::load_store(&cfg.users_file)?;
|
let mut user_store = store::load_store(&cfg.users_file)?;
|
||||||
if user_store.users.contains_key(&name) {
|
if user_store.users.contains_key(&name) {
|
||||||
|
|
@ -365,8 +376,8 @@ async fn handle(state: &AppState, name: String) -> Result<BridgeResponse> {
|
||||||
/// reader sees either the whole old file or the whole new one — and holding
|
/// reader sees either the whole old file or the whole new one — and holding
|
||||||
/// the lock would make a read wait behind a password mint, which is an
|
/// the lock would make a read wait behind a password mint, which is an
|
||||||
/// external process.
|
/// external process.
|
||||||
fn list(state: &AppState) -> Result<BridgeResponse> {
|
fn list(config: &Config) -> Result<BridgeResponse> {
|
||||||
let user_store = store::load_store(&state.config.users_file)?;
|
let user_store = store::load_store(&config.users_file)?;
|
||||||
Ok(BridgeResponse::Agents {
|
Ok(BridgeResponse::Agents {
|
||||||
agents: store::agent_names(&user_store)
|
agents: store::agent_names(&user_store)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|
@ -395,10 +406,10 @@ fn list(state: &AppState) -> Result<BridgeResponse> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The operator group's members. No write lock, for [`list`]'s reason.
|
/// The operator group's members. No write lock, for [`list`]'s reason.
|
||||||
fn list_operators(state: &AppState) -> Result<BridgeResponse> {
|
fn list_operators(config: &Config) -> Result<BridgeResponse> {
|
||||||
let user_store = store::load_store(&state.config.users_file)?;
|
let user_store = store::load_store(&config.users_file)?;
|
||||||
Ok(BridgeResponse::Operators {
|
Ok(BridgeResponse::Operators {
|
||||||
operators: store::group_members(&user_store, &state.config.operator_group),
|
operators: store::group_members(&user_store, &config.operator_group),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -444,7 +455,9 @@ async fn generate_password(bin: &std::path::Path) -> Result<String> {
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{BridgeRequest, Refusal, StatusCode, classify, introspect::Verdict, permit};
|
use super::{
|
||||||
|
BridgeRequest, Config, Refusal, StatusCode, classify, introspect::Verdict, permit, respond,
|
||||||
|
};
|
||||||
|
|
||||||
/// Fail-closed, asserted per variant rather than as one negation:
|
/// Fail-closed, asserted per variant rather than as one negation:
|
||||||
/// splitting blame out of a single denial is exactly the change that
|
/// splitting blame out of a single denial is exactly the change that
|
||||||
|
|
@ -559,4 +572,63 @@ mod tests {
|
||||||
"the token is valid; a 401 would send the caller off to mint a new one"
|
"the token is valid; a 401 would send the caller off to mint a new one"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A config whose users database holds one operator, `mara`. Every field
|
||||||
|
/// the request path does not read is left empty.
|
||||||
|
fn config_with_one_operator() -> Config {
|
||||||
|
let dir = std::env::temp_dir().join(format!(
|
||||||
|
"swarm-authelia-bridge-respond-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos()
|
||||||
|
));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let users_file = dir.join("users.yml");
|
||||||
|
std::fs::write(
|
||||||
|
&users_file,
|
||||||
|
"users:\n mara:\n displayname: mara\n password: \"$argon2id$x\"\n groups: [admins]\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
Config {
|
||||||
|
bind: String::new(),
|
||||||
|
users_file,
|
||||||
|
authelia_bin: std::path::PathBuf::new(),
|
||||||
|
introspection_url: String::new(),
|
||||||
|
client_id: String::new(),
|
||||||
|
client_secret: String::new(),
|
||||||
|
operator_reader_client_id: CONTROLLER.to_owned(),
|
||||||
|
operator_group: "admins".to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The request path, not only [`permit`]: a dispatch that skipped the
|
||||||
|
/// check would answer the hive below with the operator list.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn the_request_path_answers_the_operator_list_to_the_controller_only() {
|
||||||
|
let config = config_with_one_operator();
|
||||||
|
let lock = tokio::sync::Mutex::new(());
|
||||||
|
for caller in [Some("hive-alpha"), None] {
|
||||||
|
let refused = respond(&config, &lock, caller, BridgeRequest::ListOperators).await;
|
||||||
|
assert_eq!(refused.status(), StatusCode::FORBIDDEN, "{caller:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
let answered = respond(
|
||||||
|
&config,
|
||||||
|
&lock,
|
||||||
|
Some(CONTROLLER),
|
||||||
|
BridgeRequest::ListOperators,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(answered.status(), StatusCode::OK);
|
||||||
|
let body = axum::body::to_bytes(answered.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_slice::<serde_json::Value>(&body).unwrap(),
|
||||||
|
serde_json::json!({"status": "operators", "operators": ["mara"]})
|
||||||
|
);
|
||||||
|
let _ = std::fs::remove_dir_all(config.users_file.parent().unwrap());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,11 @@
|
||||||
//! login so far) gets no command either: tuwunel would only refuse it, every
|
//! login so far) gets no command either: tuwunel would only refuse it, every
|
||||||
//! pass.
|
//! pass.
|
||||||
//!
|
//!
|
||||||
//! The bot's reply is not read. A command that did not take shows up as the
|
//! The bot's reply is not read, and a grant is sent **at most once per
|
||||||
//! same operator still missing from the room on the next pass, which sends it
|
//! operator per controller process**. An operator still missing from the room
|
||||||
//! again.
|
//! on a later pass is logged as `make-user-admin … failed to take` and not sent
|
||||||
|
//! again until the controller restarts: the command stays in the room's
|
||||||
|
//! history, so one tuwunel ignores must not be repeated every pass.
|
||||||
|
|
||||||
use std::collections::BTreeSet;
|
use std::collections::BTreeSet;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
@ -61,11 +63,20 @@ pub struct Plan {
|
||||||
pub grant: Vec<(String, String)>,
|
pub grant: Vec<(String, String)>,
|
||||||
/// Operators whose username maps to no legal localpart.
|
/// Operators whose username maps to no legal localpart.
|
||||||
pub unmappable: Vec<String>,
|
pub unmappable: Vec<String>,
|
||||||
|
/// `(authelia username, user id)` for each operator this process already
|
||||||
|
/// sent a grant for who is still not an admin. Not sent again.
|
||||||
|
pub unconfirmed: Vec<(String, String)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decide which of `operators` need a grant on `server`, given the user ids
|
/// Decide which of `operators` need a grant on `server`, given the user ids
|
||||||
/// already joined to the admin room.
|
/// already joined to the admin room and those this process already sent a
|
||||||
pub fn plan(operators: &[String], server: &str, admins: &BTreeSet<String>) -> Plan {
|
/// grant for.
|
||||||
|
pub fn plan(
|
||||||
|
operators: &[String],
|
||||||
|
server: &str,
|
||||||
|
admins: &BTreeSet<String>,
|
||||||
|
sent: &BTreeSet<String>,
|
||||||
|
) -> Plan {
|
||||||
let mut plan = Plan::default();
|
let mut plan = Plan::default();
|
||||||
for name in operators {
|
for name in operators {
|
||||||
let Some(localpart) = localpart(name) else {
|
let Some(localpart) = localpart(name) else {
|
||||||
|
|
@ -73,7 +84,12 @@ pub fn plan(operators: &[String], server: &str, admins: &BTreeSet<String>) -> Pl
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let user_id = format!("@{localpart}:{server}");
|
let user_id = format!("@{localpart}:{server}");
|
||||||
if !admins.contains(&user_id) {
|
if admins.contains(&user_id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if sent.contains(&user_id) {
|
||||||
|
plan.unconfirmed.push((name.clone(), user_id));
|
||||||
|
} else {
|
||||||
plan.grant.push((name.clone(), user_id));
|
plan.grant.push((name.clone(), user_id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -98,12 +114,13 @@ struct Outcome {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One pass: read the operator group, and send a grant for each operator
|
/// One pass: read the operator group, and send a grant for each operator
|
||||||
/// who has a matrix account and is not an admin.
|
/// who has a matrix account, is not an admin, and is not in `sent` — the user
|
||||||
|
/// ids this process already sent one for, which a sent grant joins.
|
||||||
///
|
///
|
||||||
/// Fails as a whole when the group, the token, the server name, the admin
|
/// Fails as a whole when the group, the token, the server name, the admin
|
||||||
/// room or its members cannot be read: with any of them missing there is no
|
/// room or its members cannot be read: with any of them missing there is no
|
||||||
/// decision to make.
|
/// decision to make.
|
||||||
async fn pass(bridge: &AuthBridge, base: &str) -> Result<Outcome> {
|
async fn pass(bridge: &AuthBridge, base: &str, sent: &mut BTreeSet<String>) -> Result<Outcome> {
|
||||||
let operators = bridge
|
let operators = bridge
|
||||||
.list_operators()
|
.list_operators()
|
||||||
.await
|
.await
|
||||||
|
|
@ -130,7 +147,7 @@ async fn pass(bridge: &AuthBridge, base: &str) -> Result<Outcome> {
|
||||||
.await?;
|
.await?;
|
||||||
let admins = homeserver::joined_members(&http, base, &token, &room).await?;
|
let admins = homeserver::joined_members(&http, base, &token, &room).await?;
|
||||||
|
|
||||||
let plan = plan(&operators, server, &admins);
|
let plan = plan(&operators, server, &admins, sent);
|
||||||
for name in &plan.unmappable {
|
for name in &plan.unmappable {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
operator = %name,
|
operator = %name,
|
||||||
|
|
@ -138,10 +155,19 @@ async fn pass(bridge: &AuthBridge, base: &str) -> Result<Outcome> {
|
||||||
have an account to make admin; skipped"
|
have an account to make admin; skipped"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
for (name, user_id) in &plan.unconfirmed {
|
||||||
|
tracing::warn!(
|
||||||
|
operator = %name,
|
||||||
|
%user_id,
|
||||||
|
"make-user-admin for an operator failed to take: sent earlier by this process, \
|
||||||
|
still not in the admin room; not sent again until the controller restarts"
|
||||||
|
);
|
||||||
|
}
|
||||||
let mut outcome = Outcome::default();
|
let mut outcome = Outcome::default();
|
||||||
for (name, user_id) in &plan.grant {
|
for (name, user_id) in &plan.grant {
|
||||||
match grant(&http, base, &token, &room, user_id).await {
|
match grant(&http, base, &token, &room, user_id).await {
|
||||||
Ok(true) => {
|
Ok(true) => {
|
||||||
|
sent.insert(user_id.clone());
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
operator = %name,
|
operator = %name,
|
||||||
%user_id,
|
%user_id,
|
||||||
|
|
@ -198,10 +224,11 @@ async fn grant(
|
||||||
/// is logged and retried on the next tick; it never stops the daemon.
|
/// is logged and retried on the next tick; it never stops the daemon.
|
||||||
pub fn spawn(bridge: Arc<AuthBridge>, base: Arc<str>) {
|
pub fn spawn(bridge: Arc<AuthBridge>, base: Arc<str>) {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
|
let mut sent = BTreeSet::new();
|
||||||
let mut ticker = tokio::time::interval(RECONCILE_INTERVAL);
|
let mut ticker = tokio::time::interval(RECONCILE_INTERVAL);
|
||||||
loop {
|
loop {
|
||||||
ticker.tick().await;
|
ticker.tick().await;
|
||||||
match pass(&bridge, &base).await {
|
match pass(&bridge, &base, &mut sent).await {
|
||||||
Ok(outcome) if outcome.failed > 0 => tracing::warn!(
|
Ok(outcome) if outcome.failed > 0 => tracing::warn!(
|
||||||
granted = outcome.granted,
|
granted = outcome.granted,
|
||||||
failed = outcome.failed,
|
failed = outcome.failed,
|
||||||
|
|
@ -245,6 +272,7 @@ mod tests {
|
||||||
&names(&["mara", "vinzenz"]),
|
&names(&["mara", "vinzenz"]),
|
||||||
"t.local",
|
"t.local",
|
||||||
&ids(&["@mara:t.local", "@swarm:t.local"]),
|
&ids(&["@mara:t.local", "@swarm:t.local"]),
|
||||||
|
&BTreeSet::new(),
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
plan.grant,
|
plan.grant,
|
||||||
|
|
@ -255,7 +283,12 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_pass_with_every_operator_admin_sends_nothing() {
|
fn a_pass_with_every_operator_admin_sends_nothing() {
|
||||||
let plan = plan(&names(&["mara"]), "t.local", &ids(&["@mara:t.local"]));
|
let plan = plan(
|
||||||
|
&names(&["mara"]),
|
||||||
|
"t.local",
|
||||||
|
&ids(&["@mara:t.local"]),
|
||||||
|
&BTreeSet::new(),
|
||||||
|
);
|
||||||
assert_eq!(plan, Plan::default());
|
assert_eq!(plan, Plan::default());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -263,20 +296,61 @@ mod tests {
|
||||||
fn an_uppercase_username_maps_to_the_lowercased_account() {
|
fn an_uppercase_username_maps_to_the_lowercased_account() {
|
||||||
// tuwunel lowercases the claim, so `Mara`'s login is `@mara:`, and that
|
// tuwunel lowercases the claim, so `Mara`'s login is `@mara:`, and that
|
||||||
// account being admin already is a match, not a second grant.
|
// account being admin already is a match, not a second grant.
|
||||||
let plan = plan(&names(&["Mara"]), "t.local", &ids(&["@mara:t.local"]));
|
let plan = plan(
|
||||||
|
&names(&["Mara"]),
|
||||||
|
"t.local",
|
||||||
|
&ids(&["@mara:t.local"]),
|
||||||
|
&BTreeSet::new(),
|
||||||
|
);
|
||||||
assert_eq!(plan, Plan::default());
|
assert_eq!(plan, Plan::default());
|
||||||
assert_eq!(localpart("Mara.B_c").as_deref(), Some("mara.b_c"));
|
assert_eq!(localpart("Mara.B_c").as_deref(), Some("mara.b_c"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_name_tuwunel_would_refuse_is_reported_not_granted() {
|
fn a_name_tuwunel_would_refuse_is_reported_not_granted() {
|
||||||
let plan = plan(&names(&["bad name", "ok"]), "t.local", &BTreeSet::new());
|
let plan = plan(
|
||||||
|
&names(&["bad name", "ok"]),
|
||||||
|
"t.local",
|
||||||
|
&BTreeSet::new(),
|
||||||
|
&BTreeSet::new(),
|
||||||
|
);
|
||||||
assert_eq!(plan.grant, [("ok".to_owned(), "@ok:t.local".to_owned())]);
|
assert_eq!(plan.grant, [("ok".to_owned(), "@ok:t.local".to_owned())]);
|
||||||
assert_eq!(plan.unmappable, ["bad name"]);
|
assert_eq!(plan.unmappable, ["bad name"]);
|
||||||
assert_eq!(localpart(""), None);
|
assert_eq!(localpart(""), None);
|
||||||
assert_eq!(localpart("é"), None);
|
assert_eq!(localpart("é"), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_grant_already_sent_by_this_process_is_not_sent_again() {
|
||||||
|
// The cap on the admin room: a command tuwunel ignored leaves the
|
||||||
|
// operator outside the room, and that must not produce a second send.
|
||||||
|
let plan = plan(
|
||||||
|
&names(&["mara", "vinzenz"]),
|
||||||
|
"t.local",
|
||||||
|
&BTreeSet::new(),
|
||||||
|
&ids(&["@mara:t.local"]),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
plan.grant,
|
||||||
|
[("vinzenz".to_owned(), "@vinzenz:t.local".to_owned())]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
plan.unconfirmed,
|
||||||
|
[("mara".to_owned(), "@mara:t.local".to_owned())]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_sent_grant_that_took_is_neither_sent_nor_reported() {
|
||||||
|
let plan = plan(
|
||||||
|
&names(&["mara"]),
|
||||||
|
"t.local",
|
||||||
|
&ids(&["@mara:t.local"]),
|
||||||
|
&ids(&["@mara:t.local"]),
|
||||||
|
);
|
||||||
|
assert_eq!(plan, Plan::default());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_server_name_comes_from_the_full_user_id() {
|
fn the_server_name_comes_from_the_full_user_id() {
|
||||||
assert_eq!(server_name("@swarm:t.local"), Some("t.local"));
|
assert_eq!(server_name("@swarm:t.local"), Some("t.local"));
|
||||||
|
|
|
||||||
|
|
@ -244,16 +244,21 @@ pub async fn joined_members(
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("GET /rooms/{room_id}/joined_members"))?;
|
.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() {
|
if !status.is_success() {
|
||||||
bail!(
|
bail!("the homeserver refused to list them: {}", why(status, json));
|
||||||
"the homeserver refused to list {room_id}'s members: {}",
|
|
||||||
why(status, &json)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
json["joined"]
|
json["joined"]
|
||||||
.as_object()
|
.as_object()
|
||||||
.map(|joined| joined.keys().cloned().collect())
|
.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.
|
/// Whether `user_id` has an account on this homeserver.
|
||||||
|
|
@ -277,16 +282,22 @@ pub async fn user_exists(
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("GET /profile/{user_id}"))?;
|
.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() {
|
if status.is_success() {
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
if errcode(&json) == Some("M_NOT_FOUND") {
|
if errcode(json) == Some("M_NOT_FOUND") {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
bail!(
|
bail!("the homeserver refused: {}", why(status, json))
|
||||||
"the homeserver refused {user_id}'s profile: {}",
|
|
||||||
why(status, &json)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send `body` to `room_id` as an `m.text` message, and return its event id.
|
/// 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]
|
#[test]
|
||||||
fn a_path_segment_escapes_what_would_end_or_split_the_path() {
|
fn a_path_segment_escapes_what_would_end_or_split_the_path() {
|
||||||
assert_eq!(path_segment("#admins:t.local"), "%23admins%3At.local");
|
assert_eq!(path_segment("#admins:t.local"), "%23admins%3At.local");
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue