//! The accounts linked to one agent, as kind, name and host: what the swarm //! UI's agent panel lists, and deletes. //! //! [`link`] is the write the three link routes share: it refuses a path that //! already holds an account, so replacing one takes a delete first. //! //! Read from the paths [`crate::matrix_account`], [`crate::forge_account`] and //! [`crate::github_account`] write, plus the agent's own `main` matrix account, //! which `matrix_account::agent_token` mints into the same directory. Each //! entry is read for its host and nothing else leaves this module: //! [`LinkedAccount`] has no field a credential could land in. //! //! Naming the matrix accounts and forge labels takes `list` on each agent's //! `matrix` and `forge` metadata directories, and deleting an account takes //! `delete` on its metadata path. `controllerPolicyText` in //! `nix/host-modules/swarm-bao.nix` grants both on those paths alone. //! //! A delete removes the store entry only, and the agent is not told. Its //! GitHub and forge token files stay in place, since the units that write them //! never delete one; its matrix daemon drops the account when it next re-lists. use std::future::Future; use axum::Json; use axum::extract::State; use axum::http::StatusCode; use serde::de::{DeserializeOwned, IgnoredAny}; use serde::{Deserialize, Serialize}; use swarm_secret_client::{Error, SecretStore, forge, github, matrix}; use utoipa::{IntoParams, ToSchema}; use super::{AppState, error_problem, swarm_hive}; /// The host shown for a GitHub token. The store keeps none: a token is only /// ever used against github.com. const GITHUB_HOST: &str = "github.com"; /// Which kind of account a [`LinkedAccount`] is. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, ToSchema)] #[serde(rename_all = "snake_case")] pub enum AccountKind { Matrix, Forgejo, Github, } /// One account linked to an agent. Never carries the credential. #[derive(Clone, Debug, PartialEq, Eq, Serialize, ToSchema)] pub struct LinkedAccount { kind: AccountKind, /// The matrix account name, the forge label, or `github`. #[schema(example = "main")] name: String, /// The matrix homeserver, the forge base URL, or `github.com`. `None` for a /// matrix account stored without a homeserver. #[schema(example = "https://matrix.example.org")] host: Option, /// The agent's own matrix account, which the swarm mints and an operator /// does not link. reserved: bool, } /// The store calls a listing, a link or a delete makes, so a test can stand in /// for the store. pub(crate) trait AccountStore { /// As [`SecretStore::list`]. fn list(&self, dir: &str) -> impl Future, Error>> + Send; /// As [`SecretStore::read_optional`]. fn read_optional( &self, path: &str, ) -> impl Future, Error>> + Send; /// As [`SecretStore::write`]. fn write( &self, path: &str, value: &T, ) -> impl Future> + Send; /// As [`SecretStore::delete_all_versions`]. fn delete_all_versions(&self, path: &str) -> impl Future> + Send; } impl AccountStore for SecretStore { async fn list(&self, dir: &str) -> Result, Error> { SecretStore::list(self, dir).await } async fn read_optional( &self, path: &str, ) -> Result, Error> { SecretStore::read_optional(self, path).await } async fn write(&self, path: &str, value: &T) -> Result<(), Error> { SecretStore::write(self, path, value).await } async fn delete_all_versions(&self, path: &str) -> Result<(), Error> { SecretStore::delete_all_versions(self, path).await } } /// The store the link, delete and list routes use, as [`AppState`] holds it. #[derive(Clone)] pub(crate) enum Accounts { /// The swarm's secret store, logged in to afresh on every request: a /// [`SecretStore`] holds a token it never renews, and a store that is down /// fails only the requests made while it is down. Live, /// An in-memory store a test reads back. #[cfg(test)] Fake(std::sync::Arc), } impl Accounts { /// A store to make one request's calls on. /// /// # Errors /// Whatever [`crate::store::connect`] raises. pub(crate) async fn open(&self) -> Result { match self { Self::Live => crate::store::connect() .await .map(|s| OpenAccounts::Live(Box::new(s))), #[cfg(test)] Self::Fake(store) => Ok(OpenAccounts::Fake(store.clone())), } } } /// One request's store, from [`Accounts::open`]. pub(crate) enum OpenAccounts { Live(Box), #[cfg(test)] Fake(std::sync::Arc), } impl AccountStore for OpenAccounts { async fn list(&self, dir: &str) -> Result, Error> { match self { Self::Live(s) => s.list(dir).await, #[cfg(test)] Self::Fake(s) => s.list(dir).await, } } async fn read_optional( &self, path: &str, ) -> Result, Error> { match self { Self::Live(s) => s.read_optional(path).await, #[cfg(test)] Self::Fake(s) => s.read_optional(path).await, } } async fn write(&self, path: &str, value: &T) -> Result<(), Error> { match self { Self::Live(s) => s.write(path, value).await, #[cfg(test)] Self::Fake(s) => s.write(path, value).await, } } async fn delete_all_versions(&self, path: &str) -> Result<(), Error> { match self { Self::Live(s) => s.delete_all_versions(path).await, #[cfg(test)] Self::Fake(s) => s.delete_all_versions(path).await, } } } /// The object names directly under `dir`. A key ending in `/` is a directory /// below it, not an account. async fn objects(store: &impl AccountStore, dir: &str) -> Result, Error> { let keys = store.list(dir).await?; Ok(keys.into_iter().filter(|k| !k.ends_with('/')).collect()) } /// Every account the store holds for `agent`: matrix, then forgejo, then /// github. /// /// A missing directory lists nothing, and a listed name whose object is gone /// gets no entry. /// /// # Errors /// Anything [`SecretStore::list`] or [`SecretStore::read_optional`] does not /// treat as absence, such as a denial or an unreachable store, and an `agent` /// or listed name that is not a single path segment. pub(crate) async fn linked_accounts( store: &impl AccountStore, agent: &str, ) -> Result, Error> { let mut out = Vec::new(); for name in objects(store, &matrix::accounts_dir(agent)?).await? { let path = matrix::account_path(agent, &name)?; if let Some(c) = store.read_optional::(&path).await? { out.push(LinkedAccount { kind: AccountKind::Matrix, reserved: crate::matrix_account::is_reserved_account(&name), host: c.homeserver, name, }); } } for label in objects(store, &forge::accounts_dir(agent)?).await? { let path = forge::account_path(agent, &label)?; if let Some(a) = store.read_optional::(&path).await? { out.push(LinkedAccount { kind: AccountKind::Forgejo, name: label, host: Some(a.url), reserved: false, }); } } let path = github::account_path(agent)?; if store .read_optional::(&path) .await? .is_some() { out.push(LinkedAccount { kind: AccountKind::Github, name: "github".to_owned(), host: Some(GITHUB_HOST.to_owned()), reserved: false, }); } Ok(out) } /// List the accounts linked to an agent: names and hosts, never a credential. #[utoipa::path( get, path = "/api/hives/{hive}/agents/{agent}/linked-accounts", params( ("hive" = String, Path, description = "hive the agent runs on"), ("agent" = String, Path, description = "agent whose accounts to list"), ), responses( (status = 200, description = "the agent's linked accounts, empty when it has none", body = Vec), (status = 400, description = "the agent is not an identifier, or the hive is not in this swarm (problem+json)", body = String), (status = 500, description = "the store could not be read (problem+json)", body = String), ), tag = "agents" )] pub async fn get_linked_accounts( State(state): State, axum::extract::Path((hive, agent)): axum::extract::Path<(String, String)>, ) -> Result>, 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(); 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 accounts = linked_accounts(&store, &agent).await.map_err(|e| { tracing::warn!(%hive, %agent, error = %e, "listing linked accounts failed"); error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()) })?; Ok(Json(accounts)) } /// Why an account was not linked. #[derive(Debug)] pub(crate) enum Linking { /// An account is stored at the path already. Nothing was written. Exists, /// The store refused or could not be reached. Store(Error), } impl Linking { /// `existing` names the stored account, as in /// `agent janet already has matrix account "work"`. pub(crate) fn problem(&self, existing: &str) -> problem_details::ProblemDetails { match self { Self::Exists => error_problem( StatusCode::CONFLICT, &format!("{existing} linked; delete it first"), ), Self::Store(e) => error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), } } } /// [`Linking::Exists`] when any object is stored at `path`, whatever its /// shape: one that no longer decodes as an account still holds the name. pub(crate) async fn refuse_linked(store: &impl AccountStore, path: &str) -> Result<(), Linking> { match store.read_optional::(path).await { Ok(None) => Ok(()), Ok(Some(_)) => Err(Linking::Exists), Err(e) => Err(Linking::Store(e)), } } /// Write `value` at `path`, unless an account is stored there already. /// /// A read then a write, not one atomic step: two links racing for one path can /// both pass the check, and the later write wins. pub(crate) async fn link( store: &impl AccountStore, path: &str, value: &T, ) -> Result<(), Linking> { refuse_linked(store, path).await?; store.write(path, value).await.map_err(Linking::Store) } /// Why an account was not deleted. #[derive(Debug)] pub(crate) enum Removal { /// Nothing is stored at the account's path. NotFound, /// Revoking was asked for, and the account has no homeserver to revoke at. NoHomeserver, /// The homeserver did not revoke the token. Revoke(String), /// The store refused or could not be reached. Store(Error), } impl Removal { fn problem(&self) -> problem_details::ProblemDetails { match self { Self::NotFound => error_problem(StatusCode::NOT_FOUND, "no such account is stored"), Self::NoHomeserver => error_problem( StatusCode::CONFLICT, "no homeserver is stored for this account, so its token cannot be revoked; \ the account was not deleted", ), Self::Revoke(e) => error_problem( StatusCode::BAD_GATEWAY, &format!( "the homeserver did not revoke the token, so the account was not deleted: {e}" ), ), Self::Store(e) => error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), } } } /// The object at `path`, or [`Removal::NotFound`] when nothing is stored there. async fn stored( store: &impl AccountStore, path: &str, ) -> Result { store .read_optional(path) .await .map_err(Removal::Store)? .ok_or(Removal::NotFound) } /// Delete every version of `path`. async fn remove(store: &impl AccountStore, path: &str) -> Result<(), Removal> { store .delete_all_versions(path) .await .map_err(Removal::Store) } /// Delete the matrix account at `path`. With `revoke`, its token is first /// handed to `revoke` with the stored homeserver, and the entry stays when that /// fails. async fn remove_matrix( store: &impl AccountStore, path: &str, revoke: Option, ) -> Result<(), Removal> where F: FnOnce(String, String) -> Fut, Fut: Future>, { let credential: matrix::Credential = stored(store, path).await?; if let Some(revoke) = revoke { let homeserver = credential.homeserver.ok_or(Removal::NoHomeserver)?; revoke(homeserver, credential.value) .await .map_err(Removal::Revoke)?; } remove(store, path).await } /// Delete the forge account or GitHub token at `path`. async fn remove_plain( store: &impl AccountStore, path: &str, ) -> Result<(), Removal> { stored::(store, path).await?; remove(store, path).await } /// `delete_matrix_account`'s query. #[derive(Deserialize, IntoParams)] pub struct DeleteMatrixAccountQuery { /// Log the stored token out at its homeserver before deleting. When that /// fails, the account is not deleted. #[serde(default)] revoke: bool, } /// Delete an agent's external matrix account from the store, every version of /// it. #[utoipa::path( delete, 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 account belongs to"), ("account" = String, Path, description = "the account to delete"), DeleteMatrixAccountQuery, ), responses( (status = 204, description = "deleted"), (status = 400, description = "a name is not an identifier, the account name is not a single path segment, the account is 'main' (reserved), or the hive is not in this swarm (problem+json)", body = String), (status = 404, description = "no such account is stored (problem+json)", body = String), (status = 409, description = "revoke was asked for and the account has no homeserver stored; not deleted (problem+json)", body = String), (status = 502, description = "revoke was asked for and the homeserver did not revoke the token; not deleted (problem+json)", body = String), (status = 500, description = "the store could not be read or written (problem+json)", body = String), ), tag = "agents" )] pub async fn delete_matrix_account( State(state): State, axum::extract::Path((hive, agent, account)): axum::extract::Path<(String, String, String)>, axum::extract::Query(query): axum::extract::Query, ) -> Result { 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(); let path = matrix::account_path(&agent, &account) .map_err(|e| error_problem(StatusCode::BAD_REQUEST, &e.to_string()))?; // `agent_token` re-mints `main` on its next reconcile, so deleting it // would only rotate the agent's own token. if crate::matrix_account::is_reserved_account(&account) { return Err(error_problem( StatusCode::BAD_REQUEST, "'main' is the agent's own account, which the swarm mints — it cannot be deleted.", )); } 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 revoke = query .revoke .then_some(|homeserver: String, token: String| async move { crate::matrix_account::matrix_logout(&homeserver, &token).await }); remove_matrix(&store, &path, revoke).await.map_err(|e| { tracing::warn!(%hive, %agent, %account, error = ?e, "deleting the matrix account failed"); e.problem() })?; tracing::info!(%hive, %agent, %account, revoked = query.revoke, "matrix account deleted"); Ok(StatusCode::NO_CONTENT) } /// Delete an agent's external forge account from the store, every version of /// it. #[utoipa::path( delete, path = "/api/hives/{hive}/agents/{agent}/forge-accounts/{label}", params( ("hive" = String, Path, description = "hive the agent runs on"), ("agent" = String, Path, description = "agent the account belongs to"), ("label" = String, Path, description = "the account's label"), ), responses( (status = 204, description = "deleted"), (status = 400, description = "the agent or label is not an identifier, or the hive is not in this swarm (problem+json)", body = String), (status = 404, description = "no such account is stored (problem+json)", body = String), (status = 500, description = "the store could not be read or written (problem+json)", body = String), ), tag = "agents" )] pub async fn delete_forge_account( State(state): State, axum::extract::Path((hive, agent, label)): axum::extract::Path<(String, String, String)>, ) -> Result { 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(); let label = hive_types::Ident::parse(&label) .map_err(|reason| error_problem(StatusCode::BAD_REQUEST, reason))? .into_string(); let path = forge::account_path(&agent, &label) .map_err(|e| error_problem(StatusCode::BAD_REQUEST, &e.to_string()))?; 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()) })?; remove_plain::(&store, &path) .await .map_err(|e| { tracing::warn!(%hive, %agent, %label, error = ?e, "deleting the forge account failed"); e.problem() })?; tracing::info!(%hive, %agent, %label, "forge account deleted"); Ok(StatusCode::NO_CONTENT) } /// Delete an agent's GitHub token from the store, every version of it. #[utoipa::path( delete, path = "/api/hives/{hive}/agents/{agent}/github-account", params( ("hive" = String, Path, description = "hive the agent runs on"), ("agent" = String, Path, description = "agent the token belongs to"), ), responses( (status = 204, description = "deleted"), (status = 400, description = "the agent is not an identifier, or the hive is not in this swarm (problem+json)", body = String), (status = 404, description = "no token is stored (problem+json)", body = String), (status = 500, description = "the store could not be read or written (problem+json)", body = String), ), tag = "agents" )] pub async fn delete_github_account( State(state): State, axum::extract::Path((hive, agent)): axum::extract::Path<(String, String)>, ) -> Result { 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(); let path = github::account_path(&agent) .map_err(|e| error_problem(StatusCode::BAD_REQUEST, &e.to_string()))?; 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()) })?; remove_plain::(&store, &path) .await .map_err(|e| { tracing::warn!(%hive, %agent, error = ?e, "deleting the github token failed"); e.problem() })?; tracing::info!(%hive, %agent, "github token deleted"); Ok(StatusCode::NO_CONTENT) } #[cfg(test)] pub(crate) mod tests { use std::collections::BTreeMap; use std::sync::{Arc, Mutex}; use serde::Serialize; use serde::de::DeserializeOwned; use serde_json::{Value, json}; use swarm_secret_client::Error; use super::{ AccountKind, AccountStore, LinkedAccount, Removal, linked_accounts, remove_matrix, remove_plain, }; use swarm_secret_client::{forge, github}; /// Objects by path. A list answers the next segment of every path under /// the directory, with a trailing `/` when it goes deeper, as the store /// does. A write replaces the object and is recorded in `written`; a /// delete is recorded in `deleted` and leaves `objects` as it is. #[derive(Default)] pub(crate) struct FakeStore { objects: Mutex>, denied: bool, written: Mutex>, deleted: Mutex>, } impl FakeStore { fn with(self, path: &str, object: Value) -> Self { self.objects .lock() .expect("not poisoned") .insert(path.to_owned(), object); self } /// The paths written, in order. pub(crate) fn written(&self) -> Vec { self.written.lock().expect("not poisoned").clone() } } impl AccountStore for FakeStore { async fn list(&self, dir: &str) -> Result, Error> { if self.denied { return Err(Error::MissingEnv("BAO_ADDR")); } let prefix = format!("{dir}/"); let mut keys: Vec = self .objects .lock() .expect("not poisoned") .keys() .filter_map(|p| p.strip_prefix(&prefix)) .map(|rest| match rest.split_once('/') { Some((head, _)) => format!("{head}/"), None => rest.to_owned(), }) .collect(); keys.dedup(); Ok(keys) } async fn read_optional( &self, path: &str, ) -> Result, Error> { if self.denied { return Err(Error::MissingEnv("BAO_ADDR")); } Ok(self .objects .lock() .expect("not poisoned") .get(path) .map(|v| serde_json::from_value(v.clone()).expect("fixture decodes"))) } async fn write(&self, path: &str, value: &T) -> Result<(), Error> { if self.denied { return Err(Error::MissingEnv("BAO_ADDR")); } let value = serde_json::to_value(value).expect("serialises"); self.objects .lock() .expect("not poisoned") .insert(path.to_owned(), value); self.written .lock() .expect("not poisoned") .push(path.to_owned()); Ok(()) } async fn delete_all_versions(&self, path: &str) -> Result<(), Error> { if self.denied { return Err(Error::MissingEnv("BAO_ADDR")); } self.deleted .lock() .expect("not poisoned") .push(path.to_owned()); Ok(()) } } fn row(kind: AccountKind, name: &str, host: Option<&str>, reserved: bool) -> LinkedAccount { LinkedAccount { kind, name: name.to_owned(), host: host.map(str::to_owned), reserved, } } fn every_kind() -> FakeStore { FakeStore::default() .with( "swarm/agents/atlas/matrix/main", json!({"value": "t0k3n-main", "homeserver": "https://matrix.swarm"}), ) .with( "swarm/agents/atlas/matrix/catgirl", json!({"value": "t0k3n-cat", "homeserver": "https://matrix.example.org"}), ) .with( "swarm/agents/atlas/matrix/old", json!({"value": "t0k3n-old"}), ) .with( "swarm/agents/atlas/forge/myforge", json!({"value": "t0k3n-forge", "url": "https://forge.example.org"}), ) .with( "swarm/agents/atlas/github-token", json!({"value": "t0k3n-gh"}), ) // Another agent's accounts, and the agent's own forge token beside // its `forge/` directory: neither is a linked account of atlas. .with( "swarm/agents/red/matrix/catgirl", json!({"value": "t0k3n-red", "homeserver": "https://matrix.example.org"}), ) .with( "swarm/agents/atlas/forge-token", json!({"value": "t0k3n-own", "name": "atlas"}), ) } #[tokio::test] async fn every_kind_is_listed_with_its_name_and_host() { let got = linked_accounts(&every_kind(), "atlas") .await .expect("store answers"); assert_eq!( got, vec![ row( AccountKind::Matrix, "catgirl", Some("https://matrix.example.org"), false ), row( AccountKind::Matrix, "main", Some("https://matrix.swarm"), true ), row(AccountKind::Matrix, "old", None, false), row( AccountKind::Forgejo, "myforge", Some("https://forge.example.org"), false ), row(AccountKind::Github, "github", Some("github.com"), false), ] ); } #[tokio::test] async fn the_response_carries_no_credential() { let got = linked_accounts(&every_kind(), "atlas") .await .expect("store answers"); let body = serde_json::to_value(&got).expect("serialises"); for entry in body.as_array().expect("an array") { let mut keys: Vec<&str> = entry .as_object() .expect("an object") .keys() .map(String::as_str) .collect(); keys.sort_unstable(); assert_eq!(keys, ["host", "kind", "name", "reserved"], "{entry}"); } let text = body.to_string(); assert!(!text.contains("value"), "{text}"); assert!(!text.contains("t0k3n"), "{text}"); // The control: the same serialisation does carry the names, so the // absence above is not an empty body. assert!(text.contains("catgirl"), "{text}"); } #[tokio::test] async fn an_agent_with_nothing_stored_lists_nothing() { let got = linked_accounts(&FakeStore::default(), "atlas") .await .expect("absence is not an error"); assert!(got.is_empty(), "{got:?}"); } #[tokio::test] async fn a_store_that_refuses_is_an_error_not_an_empty_list() { let store = FakeStore { denied: true, ..every_kind() }; assert!(linked_accounts(&store, "atlas").await.is_err()); } /// The `revoke` a delete without the checkbox passes. type NoRevoke = fn(String, String) -> std::future::Ready>; fn deleted(store: &FakeStore) -> Vec { store.deleted.lock().expect("not poisoned").clone() } #[tokio::test] async fn each_kind_deletes_the_stored_account() { let store = every_kind(); remove_matrix( &store, "swarm/agents/atlas/matrix/catgirl", None::, ) .await .expect("stored"); remove_plain::(&store, "swarm/agents/atlas/forge/myforge") .await .expect("stored"); remove_plain::(&store, "swarm/agents/atlas/github-token") .await .expect("stored"); assert_eq!( deleted(&store), [ "swarm/agents/atlas/matrix/catgirl", "swarm/agents/atlas/forge/myforge", "swarm/agents/atlas/github-token", ] ); } #[tokio::test] async fn an_unknown_account_is_a_404_and_nothing_is_deleted() { let store = FakeStore::default(); let results = [ remove_matrix( &store, "swarm/agents/atlas/matrix/catgirl", None::, ) .await, remove_plain::(&store, "swarm/agents/atlas/forge/myforge").await, remove_plain::(&store, "swarm/agents/atlas/github-token").await, ]; for result in results { let removal = result.expect_err("nothing is stored"); assert!(matches!(removal, Removal::NotFound), "{removal:?}"); assert_eq!( removal.problem().status, Some(axum::http::StatusCode::NOT_FOUND) ); } assert!(deleted(&store).is_empty()); } #[tokio::test] async fn revoking_logs_out_the_stored_token_at_its_homeserver_then_deletes() { let store = every_kind(); let asked = Mutex::new(None); remove_matrix( &store, "swarm/agents/atlas/matrix/catgirl", Some(|homeserver: String, token: String| { *asked.lock().expect("not poisoned") = Some((homeserver, token)); std::future::ready(Ok(())) }), ) .await .expect("revoked"); assert_eq!( asked.into_inner().expect("not poisoned"), Some(( "https://matrix.example.org".to_owned(), "t0k3n-cat".to_owned() )) ); assert_eq!(deleted(&store), ["swarm/agents/atlas/matrix/catgirl"]); } #[tokio::test] async fn a_failed_revoke_keeps_the_account_and_says_so() { let store = every_kind(); let removal = remove_matrix( &store, "swarm/agents/atlas/matrix/catgirl", Some(|_: String, _: String| { std::future::ready(Err("/logout HTTP 502 Bad Gateway: down".to_owned())) }), ) .await .expect_err("the homeserver refused"); assert!(deleted(&store).is_empty()); let problem = removal.problem(); assert_eq!(problem.status, Some(axum::http::StatusCode::BAD_GATEWAY)); let detail = problem.detail.expect("a detail"); assert!(detail.contains("not deleted"), "{detail}"); assert!(detail.contains("down"), "{detail}"); } #[tokio::test] async fn revoking_an_account_with_no_homeserver_keeps_it() { let store = every_kind(); let removal = remove_matrix( &store, "swarm/agents/atlas/matrix/old", Some( |_: String, _: String| -> std::future::Ready> { panic!("there is no homeserver to call") }, ), ) .await .expect_err("no homeserver"); assert!(matches!(removal, Removal::NoHomeserver), "{removal:?}"); assert!(deleted(&store).is_empty()); } #[tokio::test] async fn a_store_that_refuses_a_delete_is_an_error() { let store = FakeStore { denied: true, ..every_kind() }; let removal = remove_plain::(&store, "swarm/agents/atlas/github-token") .await .expect_err("denied"); assert!(matches!(removal, Removal::Store(_)), "{removal:?}"); } #[tokio::test] async fn an_object_that_no_longer_decodes_still_refuses_a_link() { let path = "swarm/agents/atlas/github-token"; let stale = json!({"token": 1}); // The control: a typed read of this object fails, so the 409 below is // not a decode that happened to succeed. assert!(serde_json::from_value::(stale.clone()).is_err()); let store = FakeStore::default().with(path, stale); let linking = super::link( &store, path, &github::Credential { value: "t0k3n-new".to_owned(), }, ) .await .expect_err("an object is stored"); assert!(matches!(linking, super::Linking::Exists), "{linking:?}"); assert_eq!( linking .problem("agent atlas already has a github token") .status, Some(axum::http::StatusCode::CONFLICT) ); assert!(store.written().is_empty()); } /// Bare-minimum `AppState`, as `matrix_account`'s tests build it. fn state(store: &Arc) -> 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(), accounts: super::Accounts::Fake(Arc::clone(store)), } } async fn delete_matrix( store: &Arc, account: &str, revoke: bool, ) -> Result { super::delete_matrix_account( axum::extract::State(state(store)), axum::extract::Path(("pr1ma".to_owned(), "atlas".to_owned(), account.to_owned())), axum::extract::Query(super::DeleteMatrixAccountQuery { revoke }), ) .await } /// Refused before the store: `main` is stored, and stays. #[tokio::test] async fn the_agents_own_matrix_account_cannot_be_deleted() { let store = Arc::new(every_kind()); let problem = delete_matrix(&store, "main", false) .await .expect_err("'main' is reserved"); assert_eq!( problem.status, Some(axum::http::StatusCode::BAD_REQUEST), "{problem:?}" ); assert!(deleted(&store).is_empty()); } /// The control: any other account name is deleted. #[tokio::test] async fn another_matrix_account_reaches_the_store() { let store = Arc::new(every_kind()); let status = delete_matrix(&store, "catgirl", false) .await .expect("stored"); assert_eq!(status, axum::http::StatusCode::NO_CONTENT); assert_eq!(deleted(&store), ["swarm/agents/atlas/matrix/catgirl"]); } }