diff --git a/swarm-controller/src/linked_accounts.rs b/swarm-controller/src/linked_accounts.rs index 9e0de5b7..99ca8f14 100644 --- a/swarm-controller/src/linked_accounts.rs +++ b/swarm-controller/src/linked_accounts.rs @@ -24,7 +24,7 @@ use std::future::Future; use axum::Json; use axum::extract::State; use axum::http::StatusCode; -use serde::de::DeserializeOwned; +use serde::de::{DeserializeOwned, IgnoredAny}; use serde::{Deserialize, Serialize}; use swarm_secret_client::{Error, SecretStore, forge, github, matrix}; use utoipa::{IntoParams, ToSchema}; @@ -226,12 +226,10 @@ impl Linking { } } -/// [`Linking::Exists`] when an account is stored at `path`. -pub(crate) async fn refuse_linked( - store: &impl AccountStore, - path: &str, -) -> Result<(), Linking> { - match store.read_optional::(path).await { +/// [`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)), @@ -242,12 +240,12 @@ pub(crate) async fn refuse_linked( /// /// 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( +pub(crate) async fn link( store: &impl AccountStore, path: &str, value: &T, ) -> Result<(), Linking> { - refuse_linked::(store, path).await?; + refuse_linked(store, path).await?; store.write(path, value).await.map_err(Linking::Store) } @@ -846,6 +844,35 @@ pub(crate) mod tests { 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() -> super::super::AppState { super::super::AppState { diff --git a/swarm-controller/src/matrix_account.rs b/swarm-controller/src/matrix_account.rs index 96f7dc2e..f53b06ac 100644 --- a/swarm-controller/src/matrix_account.rs +++ b/swarm-controller/src/matrix_account.rs @@ -200,9 +200,7 @@ async fn link_matrix( tracing::warn!(path, error = ?e, "linking the matrix account failed"); Box::new(e.problem(existing)) }; - refuse_linked::(store, path) - .await - .map_err(refused)?; + refuse_linked(store, path).await.map_err(refused)?; let (token, homeserver, user_id) = resolve_credential(req).await?; link( store,