Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarm-controller/src/matrix_account.rs
atlas 8ad2af735e swarm-controller: refuse linking over an existing account
The matrix, forge and github link routes wrote their credential
unconditionally, so linking a name that was already linked replaced the
working account. For matrix that lost the device the agent's crypto store
belongs to (#4838).

Each route now reads the account's store path first and answers 409,
naming the existing account, when something is stored there. Nothing is
written. Replacing an account takes the delete from #4899, then a link.

The matrix route checks before password mode's login, so a refused link
mints no new device at the homeserver.

The check is a read then a write, not an atomic step; two concurrent
links to one name can still both pass it.

Closes #4856
2026-10-03 13:49:03 +02:00

724 lines
29 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`].
//!
//! **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;
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 = crate::store::connect().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::<matrix::Credential>(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 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, and an empty in-memory job graph the endpoint under test
/// never touches.
fn state() -> 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,
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: None,
swarm_name: None,
auth: None,
forge: None,
create_gate: std::sync::Arc::default(),
}
}
/// Refused before the store is reached: with `BAO_*` unset a store
/// connect would answer 500, so a 400 is the reserved-name check.
#[tokio::test]
async fn a_reserved_account_name_is_refused_before_the_store() {
for var in ["BAO_ADDR", "BAO_CLIENT_CERT", "BAO_CLIENT_KEY"] {
assert!(
std::env::var(var).is_err(),
"{var} must be unset for this test to prove anything"
);
}
let result = super::put_matrix_account(
axum::extract::State(state()),
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:?}"
);
}
/// The control for the test above: an ordinary name gets past every check
/// and fails at the store connect, so the 400 above is not what every call
/// answers.
#[tokio::test]
async fn an_ordinary_account_name_reaches_the_store() {
let result = super::put_matrix_account(
axum::extract::State(state()),
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 problem = result.expect_err("no store is configured in a test");
assert_eq!(
problem.status,
Some(axum::http::StatusCode::INTERNAL_SERVER_ERROR),
"{problem:?}"
);
}
/// 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, tests::FakeStore};
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}");
}
}