use super::{
DeviceCodeResponse, OAuthErrorResponse, OAuthTokenResponse, build_auth_record,
format_refresh_error, should_reauthenticate_after_refresh,
};
use crate::providers::chatgpt::auth::{AuthSource, Authenticator, DeviceCodeHandler};
use crate::test_utils::RecordingHttpClient;
use http::StatusCode;
#[test]
fn device_code_response_accepts_a_numeric_or_string_interval() {
for (case, interval) in [("numeric", "5"), ("string", r#""5""#)] {
let response: DeviceCodeResponse = serde_json::from_str(&format!(
r#"{{
"device_auth_id": "deviceauth_123",
"user_code": "ABCD-EFGH",
"interval": {interval}
}}"#
))
.unwrap_or_else(|error| panic!("{case}: device code response: {error}"));
assert_eq!(response.interval, Some(5), "{case}");
}
}
#[test]
fn refresh_reauth_only_on_invalid_grant() {
assert!(should_reauthenticate_after_refresh(
StatusCode::BAD_REQUEST,
Some("invalid_grant")
));
assert!(should_reauthenticate_after_refresh(
StatusCode::UNAUTHORIZED,
Some("invalid_grant")
));
assert!(!should_reauthenticate_after_refresh(
StatusCode::BAD_GATEWAY,
Some("invalid_grant")
));
assert!(!should_reauthenticate_after_refresh(
StatusCode::BAD_REQUEST,
Some("invalid_request")
));
assert!(!should_reauthenticate_after_refresh(
StatusCode::UNAUTHORIZED,
None
));
}
#[tokio::test]
async fn noninteractive_oauth_requires_sign_in_instead_of_device_flow() {
let auth = Authenticator::new(AuthSource::OAuth, None, DeviceCodeHandler::default(), false);
let err = auth
.auth_context(&RecordingHttpClient::new(""))
.await
.expect_err("missing cached auth should not start device flow")
.to_string();
assert!(err.contains("ChatGPT sign-in required"), "{err}");
}
#[test]
fn refresh_error_uses_oauth_description_when_present() {
let oauth_error = OAuthErrorResponse {
error: Some("temporarily_unavailable".into()),
error_description: Some("please retry".into()),
};
assert_eq!(
format_refresh_error(StatusCode::BAD_GATEWAY, Some(&oauth_error), ""),
"ChatGPT token refresh failed: 502 Bad Gateway temporarily_unavailable (please retry)"
);
}
#[test]
fn build_auth_record_preserves_existing_refresh_token_when_refresh_omits_one() {
let record = build_auth_record(
OAuthTokenResponse {
access_token: "access-token".into(),
refresh_token: None,
id_token: None,
},
Some("cached-refresh-token".into()),
);
assert_eq!(
record.refresh_token.as_deref(),
Some("cached-refresh-token")
);
}
#[tokio::test]
async fn cached_credential_remains_persistent_and_reusable() -> anyhow::Result<()> {
let dir = assert_fs::TempDir::new()?;
let path = dir.path().join("auth.json");
let fixture = serde_json::json!({
"access_token": "synthetic-cached-chatgpt-token",
"refresh_token": "synthetic-cached-refresh-token",
"id_token": null,
"expires_at": i64::MAX,
"account_id": "synthetic-cached-account"
});
let record: super::AuthRecord = serde_json::from_value(fixture.clone())?;
super::write_json_record(Some(&path), &record)?;
let http = RecordingHttpClient::new("");
let auth = Authenticator::new(
AuthSource::OAuth,
Some(path.clone()),
DeviceCodeHandler::default(),
false,
);
let context = auth.auth_context(&http).await?;
anyhow::ensure!(http.requests().is_empty(), "a fresh cache must not refresh");
anyhow::ensure!(
context.access_token.expose() == "synthetic-cached-chatgpt-token",
"cached access token changed"
);
anyhow::ensure!(
context.account_id.as_deref() == Some("synthetic-cached-account"),
"cached account changed"
);
let persisted: serde_json::Value = serde_json::from_slice(&std::fs::read(path)?)?;
anyhow::ensure!(persisted == fixture, "persisted cache changed");
Ok(())
}