The agent's turn-state header ($SWARM.agent-state.<agent>) gains `needs_login`, set by hive-agent from its own LoginState, so the flag reaches the swarm without hive-c0re (mara on #5268: "id prefer the agent container sending the info it needs login without hive"). - swarm-queue-client: `agent_login::Login`, the shared wire type, beside `agent_inference::Effective`. `needs_login: Option<bool>` with `#[serde(default)]`: a header from an older agent decodes as `None` (unknown), never as "needs login" or "logged in". - hive-agent: the header publisher reads the shared LoginState cell. Every writer of that cell emits a status on the bus afterwards, so a login change goes out on the next rebuild, not the 60 s refresh. - swarm-controller: `agent_login::Reported` follows every agent's header and keeps the latest flag; `GET /api/agents/status` merges it into each row as `needs_login` (null when unknown). - swarm-ui: AgentCard shows a warning-tone "login needed" badge when the row's `needs_login` is true. Refs #5268
739 lines
30 KiB
Rust
739 lines
30 KiB
Rust
//! An agent's matrix accounts, from this daemon's two sides of them.
|
|
//!
|
|
//! **The internal one** — [`agent_token`] — is the agent's own `main` account on
|
|
//! the swarm's homeserver, minted with the swarm's appservice token and stored
|
|
//! where the agent's daemon reads it. See docs/swarm/credentials.md for why
|
|
//! `main` is the one name [`put_matrix_account`] refuses to write.
|
|
//! [`hive_sender`] mints each **hive's** sender account the same way, sharing this module's helpers with [`agent_token`].
|
|
//! [`operator_admin`] makes authelia's operators homeserver admins with the same
|
|
//! appservice token.
|
|
//! [`recovery`] mints the passphrase that opens each account's server-side key
|
|
//! backup, for the swarm's accounts and external ones alike.
|
|
//!
|
|
//! **The external one** — [`put_matrix_account`] and everything under it — is
|
|
//! an account somewhere else that an operator hands us a credential for: put it
|
|
//! in the swarm's secret store.
|
|
//!
|
|
//! The agent end is `hive-matrix-daemon`, which lists the agent's accounts and
|
|
//! reads each under the agent's own certificate. No hive is in the path.
|
|
//!
|
|
//! Two credential modes, chosen by `PutMatrixAccountRequest::mode`: `token`
|
|
//! (default, back-compat with the original blind-store shape — the caller
|
|
//! already has a bearer token) and `password` (this daemon performs
|
|
//! `m.login.password` against the caller-given homeserver itself and stores
|
|
//! the resulting token; the password is never stored — only the derived token
|
|
//! is). Done here so the browser never
|
|
//! has to hold the password long enough to call an arbitrary homeserver
|
|
//! directly.
|
|
|
|
use axum::Json;
|
|
use axum::extract::State;
|
|
use axum::http::StatusCode;
|
|
use serde::{Deserialize, Serialize};
|
|
use swarm_secret_client::matrix;
|
|
use utoipa::ToSchema;
|
|
|
|
use super::linked_accounts::{AccountStore, Linking, link, refuse_linked};
|
|
use super::{AppState, error_problem, swarm_hive};
|
|
|
|
pub mod agent_token;
|
|
pub mod hive_sender;
|
|
pub mod operator_admin;
|
|
pub mod recovery;
|
|
|
|
fn default_mode() -> String {
|
|
"token".to_owned()
|
|
}
|
|
|
|
/// Env var the controller's NixOS module sets from
|
|
/// `services.hyperhive.deploy.swarm-controller.matrixHomeserverUrl` — the
|
|
/// swarm-wide default `PutMatrixAccountRequest::homeserver` falls back to
|
|
/// when a caller omits one. Read via [`configured_default_homeserver`], not
|
|
/// directly — see that fn's doc.
|
|
///
|
|
/// Also the homeserver [`agent_token`] mints agents' own accounts on. Not yet
|
|
/// consulted by [`put_matrix_account`]: `homeserver_or_configured_default`
|
|
/// below exists for a later slice of this homeserver-default rollout to call.
|
|
pub(crate) const DEFAULT_HOMESERVER_ENV: &str = "SWARM_CONTROLLER_MATRIX_HOMESERVER_URL";
|
|
|
|
/// `caller`'s own homeserver, or `default` when the caller left it unset.
|
|
/// `caller` always wins — this only fills a gap it left, never replaces a
|
|
/// value it gave.
|
|
///
|
|
/// Takes `default` as a plain parameter rather than reading
|
|
/// [`DEFAULT_HOMESERVER_ENV`] itself: a caller wants that env read done once,
|
|
/// at the edge (see [`configured_default_homeserver`]), and keeping this fn
|
|
/// pure makes it testable without mutating shared process env — three tests
|
|
/// doing exactly that raced each other under `cargo test`'s default
|
|
/// parallelism in review.
|
|
pub(crate) fn homeserver_or_configured_default(
|
|
caller: Option<String>,
|
|
default: Option<String>,
|
|
) -> Option<String> {
|
|
caller.or(default)
|
|
}
|
|
|
|
/// Reads [`DEFAULT_HOMESERVER_ENV`] fresh each call — the one place this
|
|
/// crate touches that env var, so [`homeserver_or_configured_default`] above
|
|
/// can stay a pure function.
|
|
pub(crate) fn configured_default_homeserver() -> Option<String> {
|
|
std::env::var(DEFAULT_HOMESERVER_ENV).ok()
|
|
}
|
|
|
|
/// The credential to store for one agent's external matrix account.
|
|
///
|
|
/// No `Debug` derive: this carries a password and a token, so nothing may
|
|
/// `{:?}`-log it by accident.
|
|
#[derive(Deserialize, ToSchema)]
|
|
pub struct PutMatrixAccountRequest {
|
|
/// `"token"` (default, back-compat) or `"password"`. Token mode stores
|
|
/// `token` as given; password mode logs into `homeserver` with `user_id`
|
|
/// + `password` and stores the token that comes back instead.
|
|
#[serde(default = "default_mode")]
|
|
#[schema(example = "token")]
|
|
mode: String,
|
|
/// The access token. Required (and used as given) in token mode; ignored
|
|
/// in password mode, where the token comes from the login instead. Never
|
|
/// logged, and never returned by this route.
|
|
token: Option<String>,
|
|
/// Password-mode only: the matrix user id (or bare localpart) to log in
|
|
/// as.
|
|
user_id: Option<String>,
|
|
/// Password-mode only. Never logged and never stored — only the token
|
|
/// `m.login.password` returns is.
|
|
password: Option<String>,
|
|
/// The account's homeserver, when it is not this swarm's own. Stored
|
|
/// beside the token, which is where the agent's daemon reads it.
|
|
///
|
|
/// Optional in token mode (omitted means "resolve to the agent's own
|
|
/// `services.hyperhive.agent.matrix.url` on the hive side" — this route never needs to
|
|
/// know it itself for a blind store). **Required** in password mode:
|
|
/// logging in needs somewhere to log in against, and unlike token mode
|
|
/// there is no hive-side fallback to defer to.
|
|
#[schema(example = "https://matrix.example.org")]
|
|
homeserver: Option<String>,
|
|
}
|
|
|
|
/// `put_matrix_account`'s success body.
|
|
#[derive(Debug, Serialize, ToSchema)]
|
|
pub struct PutMatrixAccountResponse {
|
|
/// The account's matrix user id, recovered from the login response in
|
|
/// password mode. `None` in token mode — the caller already knows which
|
|
/// account their own token belongs to, and this route does not spend a
|
|
/// `whoami` round trip validating a token it was simply handed.
|
|
user_id: Option<String>,
|
|
}
|
|
|
|
/// Store an agent's external matrix account credential, unless an account is
|
|
/// stored under the name already.
|
|
#[utoipa::path(
|
|
put,
|
|
path = "/api/hives/{hive}/agents/{agent}/matrix-accounts/{account}",
|
|
params(
|
|
("hive" = String, Path, description = "hive the agent runs on"),
|
|
("agent" = String, Path, description = "agent the credential belongs to"),
|
|
("account" = String, Path, description = "the external account this credential authenticates as"),
|
|
),
|
|
request_body = PutMatrixAccountRequest,
|
|
responses(
|
|
(status = 200, description = "stored", body = PutMatrixAccountResponse),
|
|
(status = 400, description = "a name is not an identifier, the account name is not a single path segment, the account is 'main' (reserved), the mode is unrecognized, a mode's required fields are missing, or the hive is not in this swarm (problem+json)", body = String),
|
|
(status = 409, description = "an account is stored under the name already; nothing was written and no login was made (problem+json)", body = String),
|
|
(status = 500, description = "the store could not be read or written (problem+json)", body = String),
|
|
),
|
|
tag = "agents"
|
|
)]
|
|
pub async fn put_matrix_account(
|
|
State(state): State<AppState>,
|
|
axum::extract::Path((hive, agent, account)): axum::extract::Path<(String, String, String)>,
|
|
Json(req): Json<PutMatrixAccountRequest>,
|
|
) -> Result<Json<PutMatrixAccountResponse>, problem_details::ProblemDetails> {
|
|
let hive = swarm_hive(&state, &hive).map_err(|(s, d)| error_problem(s, &d))?;
|
|
let agent = hive_types::Ident::parse(&agent)
|
|
.map_err(|reason| error_problem(StatusCode::BAD_REQUEST, reason))?
|
|
.into_string();
|
|
// Built before the store is reached, so a malformed account name costs a
|
|
// parse and not a login. `account_path` is the validator: the store's
|
|
// charset is its rule to state, not this handler's to restate.
|
|
let secret_path = matrix::account_path(&agent, &account)
|
|
.map_err(|e| error_problem(StatusCode::BAD_REQUEST, &e.to_string()))?;
|
|
// `main` is the hive-internal account `nix/agent-modules/matrix.nix`
|
|
// declares per agent from `services.hyperhive.agent.matrix.url` — the
|
|
// module owns that `matrixAccounts` entry, which is why this route
|
|
// refuses to write one: an extra account literally named `main` would
|
|
// not overwrite the real one (it lands at a different token-file suffix)
|
|
// but would confuse anything that lists accounts by name. Checked
|
|
// before any mode-specific work (including a network login) runs.
|
|
if is_reserved_account(&account) {
|
|
return Err(error_problem(
|
|
StatusCode::BAD_REQUEST,
|
|
"'main' is the hive-internal account, declared per agent from \
|
|
services.hyperhive.agent.matrix.url — it cannot be set through this route.",
|
|
));
|
|
}
|
|
|
|
let store = state.accounts.open().await.map_err(|e| {
|
|
tracing::warn!(error = %e, "connecting to the swarm secret store failed");
|
|
error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
|
|
})?;
|
|
let user_id = link_matrix(&store, &secret_path, &existing(&agent, &account), &req)
|
|
.await
|
|
.map_err(|b| *b)?;
|
|
|
|
tracing::info!(%hive, %agent, %account, "credential stored");
|
|
Ok(Json(PutMatrixAccountResponse { user_id }))
|
|
}
|
|
|
|
/// The account a refused link names.
|
|
fn existing(agent: &str, account: &str) -> String {
|
|
format!("agent {agent} already has matrix account {account:?}")
|
|
}
|
|
|
|
/// Resolve `req`'s credential and write it at `path`, unless an account is
|
|
/// stored there already; `existing` names that account in the 409.
|
|
///
|
|
/// The check comes before password mode's login, so a refused link makes no
|
|
/// login and so mints no device at the homeserver. A failed login writes
|
|
/// nothing.
|
|
async fn link_matrix(
|
|
store: &impl AccountStore,
|
|
path: &str,
|
|
existing: &str,
|
|
req: &PutMatrixAccountRequest,
|
|
) -> Result<Option<String>, Box<problem_details::ProblemDetails>> {
|
|
let refused = |e: Linking| {
|
|
// The path names the agent and the account; the value is not in it.
|
|
tracing::warn!(path, error = ?e, "linking the matrix account failed");
|
|
Box::new(e.problem(existing))
|
|
};
|
|
refuse_linked(store, path).await.map_err(refused)?;
|
|
let (token, homeserver, user_id) = resolve_credential(req).await?;
|
|
link(
|
|
store,
|
|
path,
|
|
&matrix::Credential {
|
|
value: token,
|
|
homeserver,
|
|
},
|
|
)
|
|
.await
|
|
.map_err(refused)?;
|
|
Ok(user_id)
|
|
}
|
|
|
|
/// Whether `account` is the agent's own account, which [`agent_token`] mints
|
|
/// and `nix/agent-modules/matrix.nix` declares per agent — see
|
|
/// [`put_matrix_account`]'s comment on it for why that route must never write
|
|
/// one. `linked_accounts::delete_matrix_account` refuses to delete it too.
|
|
pub(crate) fn is_reserved_account(account: &str) -> bool {
|
|
account == agent_token::ACCOUNT
|
|
}
|
|
|
|
/// Token-mode's only requirement: a token was actually given. Split out of
|
|
/// `resolve_credential` (with `password_fields` below) so each mode's
|
|
/// validation is unit-testable directly — only the actual network login in
|
|
/// `resolve_credential` itself needs an async runtime to exercise. Returns a
|
|
/// plain `&'static str` rather than a `ProblemDetails` — `clippy::result_large_err`
|
|
/// (this workspace runs `pedantic = deny`) flags a private fn returning one of
|
|
/// those directly; `put_matrix_account` itself is exempt only because it is
|
|
/// `pub` (clippy's `avoid-breaking-exported-api` default), which these
|
|
/// helpers are not.
|
|
fn token_credential(
|
|
req: &PutMatrixAccountRequest,
|
|
) -> Result<(String, Option<String>), &'static str> {
|
|
let Some(token) = req.token.clone() else {
|
|
return Err("token mode needs a token");
|
|
};
|
|
Ok((token, req.homeserver.clone()))
|
|
}
|
|
|
|
/// Password mode's required fields, validated present, with the
|
|
/// homeserver's trailing slash already trimmed — this is what actually
|
|
/// reaches `matrix_password_login`, not the caller's raw string, so the
|
|
/// login URL below never ends up with a doubled `//`.
|
|
struct PasswordFields<'a> {
|
|
homeserver: String,
|
|
user_id: &'a str,
|
|
password: &'a str,
|
|
}
|
|
|
|
fn password_fields(req: &PutMatrixAccountRequest) -> Result<PasswordFields<'_>, &'static str> {
|
|
let (Some(homeserver), Some(user_id), Some(password)) = (
|
|
req.homeserver.as_deref(),
|
|
req.user_id.as_deref(),
|
|
req.password.as_deref(),
|
|
) else {
|
|
return Err("password mode needs homeserver, user_id, and password");
|
|
};
|
|
Ok(PasswordFields {
|
|
homeserver: homeserver.trim_end_matches('/').to_owned(),
|
|
user_id,
|
|
password,
|
|
})
|
|
}
|
|
|
|
/// Resolve a request's credential mode to a concrete `(token, homeserver,
|
|
/// resolved user id)`. Extracted out of `put_matrix_account` to keep that
|
|
/// function under `clippy::too_many_lines`, and so the mode branching has
|
|
/// something to unit-test directly (via `token_credential`/`password_fields`
|
|
/// above) instead of only through the full route.
|
|
///
|
|
/// `Box`ed error for the same `result_large_err` reason `token_credential`'s
|
|
/// doc explains — this fn is private too, so it does not get `put_matrix_account`'s
|
|
/// exported-API exemption. Unboxed in `put_matrix_account` instead of
|
|
/// changing that fn's own (exempt, and part of the route's documented
|
|
/// contract) return type.
|
|
async fn resolve_credential(
|
|
req: &PutMatrixAccountRequest,
|
|
) -> Result<(String, Option<String>, Option<String>), Box<problem_details::ProblemDetails>> {
|
|
match req.mode.as_str() {
|
|
"token" => {
|
|
let (token, homeserver) = token_credential(req)
|
|
.map_err(|e| Box::new(error_problem(StatusCode::BAD_REQUEST, e)))?;
|
|
Ok((token, homeserver, None))
|
|
}
|
|
"password" => {
|
|
let fields = password_fields(req)
|
|
.map_err(|e| Box::new(error_problem(StatusCode::BAD_REQUEST, e)))?;
|
|
let (token, resolved_user_id) =
|
|
matrix_password_login(&fields.homeserver, fields.user_id, fields.password)
|
|
.await
|
|
.map_err(|e| Box::new(error_problem(StatusCode::BAD_REQUEST, &e)))?;
|
|
Ok((token, Some(fields.homeserver), Some(resolved_user_id)))
|
|
}
|
|
other => Err(Box::new(error_problem(
|
|
StatusCode::BAD_REQUEST,
|
|
&format!("unknown mode {other:?} (want token|password)"),
|
|
))),
|
|
}
|
|
}
|
|
|
|
/// Bound on reaching the homeserver.
|
|
const HTTP_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
|
|
/// Bound on one whole homeserver round trip, body included. A password
|
|
/// login makes the homeserver hash the password before it answers, so this
|
|
/// is looser than a plain API call needs.
|
|
const HTTP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
|
|
|
|
/// A client with both homeserver bounds applied.
|
|
fn http_client() -> Result<reqwest::Client, String> {
|
|
reqwest::Client::builder()
|
|
.connect_timeout(HTTP_CONNECT_TIMEOUT)
|
|
.timeout(HTTP_TIMEOUT)
|
|
.build()
|
|
.map_err(|e| format!("build HTTP client: {e}"))
|
|
}
|
|
|
|
/// `what` failed with `e`; a timeout names the bound that fired.
|
|
fn http_error(what: &str, e: &reqwest::Error) -> String {
|
|
if e.is_connect() && e.is_timeout() {
|
|
format!("{what}: connect timed out after {HTTP_CONNECT_TIMEOUT:?}")
|
|
} else if e.is_timeout() {
|
|
format!("{what}: timed out after {HTTP_TIMEOUT:?}")
|
|
} else {
|
|
format!("{what}: {e}")
|
|
}
|
|
}
|
|
|
|
/// POST `m.login.password` to `<homeserver>/_matrix/client/v3/login`.
|
|
/// Returns `(access_token, user_id)`.
|
|
///
|
|
/// Deliberately its own copy rather than a shared crate with
|
|
/// `hive-c0re::dashboard::matrix_accounts`'s near-identical helper: the two
|
|
/// log in on behalf of different callers (a hive's own dashboard vs. this
|
|
/// swarm-wide provisioning route) and share no other code — four lines of
|
|
/// JSON construction do not justify a dependency edge between them.
|
|
async fn matrix_password_login(
|
|
homeserver: &str,
|
|
user_id: &str,
|
|
password: &str,
|
|
) -> Result<(String, String), String> {
|
|
let url = format!("{homeserver}/_matrix/client/v3/login");
|
|
let body = serde_json::json!({
|
|
"type": "m.login.password",
|
|
"identifier": { "type": "m.id.user", "user": user_id },
|
|
"password": password,
|
|
"initial_device_display_name": "hyperhive",
|
|
});
|
|
let resp = http_client()?
|
|
.post(&url)
|
|
.json(&body)
|
|
.send()
|
|
.await
|
|
.map_err(|e| http_error("POST /login", &e))?;
|
|
let status = resp.status();
|
|
let json: serde_json::Value = resp
|
|
.json()
|
|
.await
|
|
.map_err(|e| http_error("parse /login response", &e))?;
|
|
if !status.is_success() {
|
|
let err = json
|
|
.get("error")
|
|
.and_then(serde_json::Value::as_str)
|
|
.unwrap_or("login failed");
|
|
return Err(format!("/login HTTP {status}: {err}"));
|
|
}
|
|
let token = json
|
|
.get("access_token")
|
|
.and_then(serde_json::Value::as_str)
|
|
.ok_or_else(|| "login response missing access_token".to_owned())?;
|
|
let uid = json
|
|
.get("user_id")
|
|
.and_then(serde_json::Value::as_str)
|
|
.ok_or_else(|| "login response missing user_id".to_owned())?;
|
|
Ok((token.to_owned(), uid.to_owned()))
|
|
}
|
|
|
|
/// POST `<homeserver>/_matrix/client/v3/logout` with `token`, which
|
|
/// invalidates that token at the homeserver.
|
|
///
|
|
/// A 401 `M_UNKNOWN_TOKEN` means the homeserver already does not recognise
|
|
/// the token — the state a logout wants — so that one answer counts as
|
|
/// success rather than failure. Every other non-2xx is a failure.
|
|
///
|
|
/// The error names the status and the homeserver's `error` text, never the
|
|
/// token.
|
|
pub(crate) async fn matrix_logout(homeserver: &str, token: &str) -> Result<(), String> {
|
|
let url = format!(
|
|
"{}/_matrix/client/v3/logout",
|
|
homeserver.trim_end_matches('/')
|
|
);
|
|
let resp = http_client()?
|
|
.post(&url)
|
|
.bearer_auth(token)
|
|
.json(&serde_json::json!({}))
|
|
.send()
|
|
.await
|
|
.map_err(|e| http_error("POST /logout", &e))?;
|
|
let status = resp.status();
|
|
if status.is_success() {
|
|
return Ok(());
|
|
}
|
|
let body = resp.json::<serde_json::Value>().await.ok();
|
|
if status == StatusCode::UNAUTHORIZED
|
|
&& body
|
|
.as_ref()
|
|
.and_then(|j| j.get("errcode"))
|
|
.and_then(serde_json::Value::as_str)
|
|
== Some("M_UNKNOWN_TOKEN")
|
|
{
|
|
return Ok(());
|
|
}
|
|
let err = body
|
|
.and_then(|j| {
|
|
j.get("error")
|
|
.and_then(serde_json::Value::as_str)
|
|
.map(str::to_owned)
|
|
})
|
|
.unwrap_or_else(|| "logout failed".to_owned());
|
|
Err(format!("/logout HTTP {status}: {err}"))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::sync::Arc;
|
|
|
|
use super::super::linked_accounts::{Accounts, tests::FakeStore};
|
|
use super::{
|
|
PutMatrixAccountRequest, homeserver_or_configured_default, is_reserved_account,
|
|
matrix_logout, password_fields, resolve_credential, token_credential,
|
|
};
|
|
|
|
fn request(mode: &str) -> PutMatrixAccountRequest {
|
|
PutMatrixAccountRequest {
|
|
mode: mode.to_owned(),
|
|
token: None,
|
|
user_id: None,
|
|
password: None,
|
|
homeserver: None,
|
|
}
|
|
}
|
|
|
|
// `homeserver_or_configured_default` takes its default as a plain
|
|
// parameter rather than reading the env var itself, so these are pure
|
|
// — no process env mutation, and so no risk of racing each other (or
|
|
// any other test in the crate) under `cargo test`'s default parallelism.
|
|
|
|
#[test]
|
|
fn caller_supplied_homeserver_wins_even_with_a_default_configured() {
|
|
let result = homeserver_or_configured_default(
|
|
Some("https://caller.example.org".to_owned()),
|
|
Some("https://default.example.org".to_owned()),
|
|
);
|
|
assert_eq!(result.as_deref(), Some("https://caller.example.org"));
|
|
}
|
|
|
|
#[test]
|
|
fn falls_back_to_the_configured_default_when_the_caller_omits_one() {
|
|
let result =
|
|
homeserver_or_configured_default(None, Some("https://default.example.org".to_owned()));
|
|
assert_eq!(result.as_deref(), Some("https://default.example.org"));
|
|
}
|
|
|
|
#[test]
|
|
fn none_when_neither_caller_nor_default_is_set() {
|
|
assert_eq!(homeserver_or_configured_default(None, None), None);
|
|
}
|
|
|
|
#[test]
|
|
fn main_is_reserved_but_nothing_else_is() {
|
|
assert!(is_reserved_account("main"));
|
|
assert!(!is_reserved_account("ops-relay"));
|
|
// Case-sensitive on purpose: `matrixAccounts` is a nix attrset, so
|
|
// `Main` is a distinct, legal key from the reserved `main`.
|
|
assert!(!is_reserved_account("Main"));
|
|
}
|
|
|
|
#[test]
|
|
fn token_mode_needs_a_token() {
|
|
assert!(token_credential(&request("token")).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn token_mode_passes_through_token_and_homeserver_unchanged() {
|
|
let mut req = request("token");
|
|
req.token = Some("t0k3n".to_owned());
|
|
req.homeserver = Some("https://matrix.example.org".to_owned());
|
|
let (token, homeserver) = token_credential(&req).expect("token + homeserver both given");
|
|
assert_eq!(token, "t0k3n");
|
|
assert_eq!(homeserver.as_deref(), Some("https://matrix.example.org"));
|
|
}
|
|
|
|
#[test]
|
|
fn password_mode_needs_all_three_fields() {
|
|
let mut req = request("password");
|
|
assert!(password_fields(&req).is_err(), "none given");
|
|
req.homeserver = Some("https://matrix.example.org".to_owned());
|
|
assert!(
|
|
password_fields(&req).is_err(),
|
|
"still missing user_id + password"
|
|
);
|
|
req.user_id = Some("@a:matrix.example.org".to_owned());
|
|
assert!(password_fields(&req).is_err(), "still missing password");
|
|
req.password = Some("hunter2".to_owned());
|
|
assert!(password_fields(&req).is_ok(), "now all three given");
|
|
}
|
|
|
|
#[test]
|
|
fn password_mode_trims_a_trailing_slash_off_the_homeserver() {
|
|
let mut req = request("password");
|
|
req.homeserver = Some("https://matrix.example.org/".to_owned());
|
|
req.user_id = Some("@a:matrix.example.org".to_owned());
|
|
req.password = Some("hunter2".to_owned());
|
|
let fields = password_fields(&req).expect("all three fields given");
|
|
assert_eq!(fields.homeserver, "https://matrix.example.org");
|
|
}
|
|
|
|
#[test]
|
|
fn password_mode_leaves_a_homeserver_with_no_trailing_slash_unchanged() {
|
|
let mut req = request("password");
|
|
req.homeserver = Some("https://matrix.example.org".to_owned());
|
|
req.user_id = Some("@a:matrix.example.org".to_owned());
|
|
req.password = Some("hunter2".to_owned());
|
|
let fields = password_fields(&req).expect("all three fields given");
|
|
assert_eq!(fields.homeserver, "https://matrix.example.org");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn resolve_credential_token_mode_end_to_end() {
|
|
let mut req = request("token");
|
|
req.token = Some("t0k3n".to_owned());
|
|
let (token, homeserver, user_id) = resolve_credential(&req)
|
|
.await
|
|
.expect("token mode with a token given");
|
|
assert_eq!(token, "t0k3n");
|
|
assert_eq!(homeserver, None);
|
|
assert_eq!(user_id, None);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn resolve_credential_rejects_an_unknown_mode() {
|
|
assert!(
|
|
resolve_credential(&request("carrier-pigeon"))
|
|
.await
|
|
.is_err()
|
|
);
|
|
}
|
|
|
|
/// Bare-minimum `AppState` for a handler test: one hive, nothing
|
|
/// queue-backed, an empty in-memory job graph the endpoint under test
|
|
/// never touches, and `store` as the account store.
|
|
fn state(store: &Arc<FakeStore>) -> super::super::AppState {
|
|
super::super::AppState {
|
|
hives: std::sync::Arc::new(vec![super::super::HiveEntry {
|
|
name: "pr1ma".to_owned(),
|
|
domain: "pr1ma.example".to_owned(),
|
|
}]),
|
|
links: std::sync::Arc::new(Vec::new()),
|
|
status: None,
|
|
wanted: None,
|
|
agent_status: None,
|
|
agent_icons: None,
|
|
reported_inference: None,
|
|
agent_login: None,
|
|
jobq: std::sync::Arc::new(std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new(
|
|
hive_jobq::Graph::new(),
|
|
hive_jobq::resources::ResourceTable::new(),
|
|
))),
|
|
webhook_secret: None,
|
|
config_prs: std::sync::Arc::default(),
|
|
swarm_name: None,
|
|
auth: None,
|
|
forge: std::sync::Arc::default(),
|
|
create_gate: std::sync::Arc::default(),
|
|
accounts: Accounts::Fake(Arc::clone(store)),
|
|
try_outs: super::super::review_classifier::TryOuts::default(),
|
|
reviews: std::sync::Arc::default(),
|
|
runs: crate::review_classifier::runs::Runs::Live(None),
|
|
}
|
|
}
|
|
|
|
/// Refused before the store is reached.
|
|
#[tokio::test]
|
|
async fn a_reserved_account_name_is_refused_before_the_store() {
|
|
let store = Arc::default();
|
|
let result = super::put_matrix_account(
|
|
axum::extract::State(state(&store)),
|
|
axum::extract::Path(("pr1ma".to_owned(), "atlas".to_owned(), "main".to_owned())),
|
|
axum::Json(PutMatrixAccountRequest {
|
|
mode: "token".to_owned(),
|
|
token: Some("t0k3n".to_owned()),
|
|
user_id: None,
|
|
password: None,
|
|
homeserver: None,
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let problem = result.expect_err("'main' is reserved");
|
|
assert_eq!(
|
|
problem.status,
|
|
Some(axum::http::StatusCode::BAD_REQUEST),
|
|
"{problem:?}"
|
|
);
|
|
assert!(store.written().is_empty());
|
|
}
|
|
|
|
/// The control for the test above: an ordinary name gets past every check
|
|
/// and is written, so the 400 above is not what every call answers.
|
|
#[tokio::test]
|
|
async fn an_ordinary_account_name_reaches_the_store() {
|
|
use super::super::linked_accounts::AccountStore;
|
|
use swarm_secret_client::matrix;
|
|
|
|
let store = Arc::default();
|
|
let result = super::put_matrix_account(
|
|
axum::extract::State(state(&store)),
|
|
axum::extract::Path((
|
|
"pr1ma".to_owned(),
|
|
"atlas".to_owned(),
|
|
"workaccount".to_owned(),
|
|
)),
|
|
axum::Json(PutMatrixAccountRequest {
|
|
mode: "token".to_owned(),
|
|
token: Some("t0k3n".to_owned()),
|
|
user_id: None,
|
|
password: None,
|
|
homeserver: None,
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let response = result.expect("nothing is stored");
|
|
assert_eq!(response.user_id, None);
|
|
let path = "swarm/agents/atlas/matrix/workaccount";
|
|
assert_eq!(store.written(), [path]);
|
|
let stored: matrix::Credential = store
|
|
.read_optional(path)
|
|
.await
|
|
.expect("store answers")
|
|
.expect("stored");
|
|
assert_eq!(stored.value, "t0k3n");
|
|
}
|
|
|
|
/// The second link is password mode against a port nothing listens on:
|
|
/// a login attempt would answer 400, so the 409 is the check running first.
|
|
#[tokio::test]
|
|
async fn linking_a_name_twice_is_a_409_and_the_first_account_stays() {
|
|
use super::super::linked_accounts::AccountStore;
|
|
use swarm_secret_client::matrix;
|
|
|
|
let store = FakeStore::default();
|
|
let path = matrix::account_path("atlas", "workaccount").expect("a valid path");
|
|
let existing = &super::existing("atlas", "workaccount");
|
|
let mut first = request("token");
|
|
first.token = Some("t0k3n-first".to_owned());
|
|
first.homeserver = Some("https://matrix.example.org".to_owned());
|
|
let mut second = request("password");
|
|
second.homeserver = Some("http://127.0.0.1:9".to_owned());
|
|
second.user_id = Some("@a:matrix.example.org".to_owned());
|
|
second.password = Some("hunter2".to_owned());
|
|
|
|
super::link_matrix(&store, &path, existing, &first)
|
|
.await
|
|
.expect("nothing is stored");
|
|
let problem = super::link_matrix(&store, &path, existing, &second)
|
|
.await
|
|
.expect_err("an account is stored");
|
|
|
|
assert_eq!(problem.status, Some(axum::http::StatusCode::CONFLICT));
|
|
let detail = problem.detail.expect("a detail");
|
|
assert_eq!(
|
|
detail,
|
|
r#"agent atlas already has matrix account "workaccount" linked; delete it first"#
|
|
);
|
|
assert_eq!(store.written(), std::slice::from_ref(&path));
|
|
let kept: matrix::Credential = store
|
|
.read_optional(&path)
|
|
.await
|
|
.expect("store answers")
|
|
.expect("still stored");
|
|
assert_eq!(kept.value, "t0k3n-first");
|
|
assert_eq!(
|
|
kept.homeserver.as_deref(),
|
|
Some("https://matrix.example.org")
|
|
);
|
|
}
|
|
|
|
/// A stand-in homeserver on a loopback port, answering every
|
|
/// `/_matrix/client/v3/logout` with `status` and `body`.
|
|
async fn stub_homeserver(status: u16, body: &'static str) -> String {
|
|
let app = axum::Router::new().route(
|
|
"/_matrix/client/v3/logout",
|
|
axum::routing::post(move || async move {
|
|
(
|
|
axum::http::StatusCode::from_u16(status).unwrap(),
|
|
[(axum::http::header::CONTENT_TYPE, "application/json")],
|
|
body,
|
|
)
|
|
}),
|
|
);
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let addr = listener.local_addr().unwrap();
|
|
tokio::spawn(async move { axum::serve(listener, app).await });
|
|
format!("http://{addr}")
|
|
}
|
|
|
|
/// A revoke whose token the homeserver already does not recognise has
|
|
/// reached logout's end state, not failed it.
|
|
#[tokio::test]
|
|
async fn logout_treats_401_m_unknown_token_as_already_revoked() {
|
|
let homeserver = stub_homeserver(
|
|
401,
|
|
r#"{"errcode":"M_UNKNOWN_TOKEN","error":"Access token invalid"}"#,
|
|
)
|
|
.await;
|
|
assert!(matrix_logout(&homeserver, "t0k3n").await.is_ok());
|
|
}
|
|
|
|
/// The control: a 401 with a different errcode is still a failure, so
|
|
/// the fold above is specific to `M_UNKNOWN_TOKEN` and not any 401.
|
|
#[tokio::test]
|
|
async fn logout_fails_on_other_401s() {
|
|
let homeserver = stub_homeserver(401, r#"{"errcode":"M_FORBIDDEN","error":"nope"}"#).await;
|
|
let err = matrix_logout(&homeserver, "t0k3n")
|
|
.await
|
|
.expect_err("not M_UNKNOWN_TOKEN");
|
|
assert!(err.contains("401"), "{err}");
|
|
}
|
|
}
|