diff --git a/src/clients/atproto/oauth/store.rs b/src/clients/atproto/oauth/store.rs index 659977a..f11ae52 100644 --- a/src/clients/atproto/oauth/store.rs +++ b/src/clients/atproto/oauth/store.rs @@ -342,6 +342,52 @@ impl ClientAuthStore for SessionStore { mod tests { use super::*; + /// What jacquard treats as a dead grant, pinned here because atgc's whole + /// credential design rests on it and it lives in somebody else's crate. + /// + /// `get_refreshed` deletes the session when `is_permanent` answers true. + /// The difference between "the network was down for a moment" and "this + /// account is logged out" is therefore one match arm in a vendored + /// dependency, and an upgrade that widened it -- a timeout classified as + /// permanent, a 500 read as a refusal -- would delete live sessions across + /// the machine with nothing here to notice, because nothing else in this + /// crate asserts it. + /// + /// Both directions matter. A store failure must never be permanent, or a + /// full disk logs the whole fleet out; a real `invalid_grant` must stay + /// permanent, or a genuinely dead session is retried forever. + #[test] + fn only_the_server_refusing_the_grant_counts_as_permanent() { + use jacquard::oauth::request::RequestError; + use jacquard::oauth::session::Error; + + let refused = + |body: Value| RequestError::http_status_with_body(http::StatusCode::BAD_REQUEST, body); + + assert!( + Error::ServerAgent(refused(serde_json::json!({"error": "invalid_grant"}))) + .is_permanent(), + "a refused grant is no longer permanent, so a dead session is retried forever" + ); + + for body in [ + serde_json::json!({"error": "server_error"}), + serde_json::json!({"error": "temporarily_unavailable"}), + serde_json::json!({}), + ] { + assert!( + !Error::ServerAgent(refused(body.clone())).is_permanent(), + "{body} is now classified permanent, which deletes a live session" + ); + } + + let ours = into_store_error(anyhow::anyhow!("no space left on device")); + assert!( + !Error::Store(ours).is_permanent(), + "a store failure is now permanent, so a full disk logs the account out" + ); + } + /// A throwaway store directory, named for the test so a leftover from a /// killed run says which one made it. fn store_dir(label: &str) -> tempfile::TempDir {