mod support;
use cratefield_module_connections::{ConnectionError, ConnectionStatus, Provider, presets};
use support::{
CLIENT_ID, CLIENT_SECRET, CONNECTED_BODY, INVALID_GRANT, REFRESHED_BODY, REFRESHED_NO_EXPIRY,
REFRESHED_NO_ROTATION, Spec, connect, fixture,
};
struct Case {
provider: Provider,
key: &'static str,
token_url: &'static str,
basic: bool,
rotates: bool,
}
fn cases() -> Vec<Case> {
vec![
Case {
provider: presets::x(),
key: "x",
token_url: "https://api.x.com/2/oauth2/token",
basic: true,
rotates: true,
},
Case {
provider: presets::google(),
key: "google",
token_url: "https://oauth2.googleapis.com/token",
basic: true,
rotates: false,
},
Case {
provider: presets::linkedin(),
key: "linkedin",
token_url: "https://www.linkedin.com/oauth/v2/accessToken",
basic: false,
rotates: false,
},
Case {
provider: presets::vercel(),
key: "vercel",
token_url: "https://api.vercel.com/login/oauth/token",
basic: false,
rotates: true,
},
Case {
provider: presets::gitlab(),
key: "gitlab",
token_url: "https://gitlab.com/oauth/token",
basic: false,
rotates: true,
},
Case {
provider: presets::linear(),
key: "linear",
token_url: "https://api.linear.app/oauth/token",
basic: false,
rotates: true,
},
]
}
fn refresh_body(rotates: bool) -> &'static str {
if rotates {
REFRESHED_BODY
} else {
REFRESHED_NO_ROTATION
}
}
#[pollster::test]
async fn refreshing_follows_the_preset_and_an_invalid_grant_asks_for_a_reconnect() {
for case in cases() {
for kit in fixture(&Spec::only(case.provider.clone())).kits {
kit.fresh();
assert_eq!(
case.provider.rotates_refresh(),
case.rotates,
"{}",
case.key
);
let connection = connect(&kit, "alice", case.key).await;
kit.http.reset();
kit.clock.advance_secs(3600);
kit.http.set_response(200, refresh_body(case.rotates));
let token = kit
.api
.access_token(&connection.id)
.await
.expect("the refresh succeeds");
kit.drain().await;
assert_eq!(token.expose(), "AT-refreshed-2", "{}", case.key);
assert!(kit.events.has("connections.refreshed"), "{}", case.key);
let call = kit.http.last_token_call().expect("a token call");
assert_eq!(
call.uri, case.token_url,
"{} posted to the wrong endpoint",
case.key
);
assert_eq!(
call.content_type.as_deref(),
Some("application/x-www-form-urlencoded"),
"{} did not form-encode the refresh",
case.key
);
assert_eq!(call.field("grant_type").as_deref(), Some("refresh_token"));
assert_eq!(
call.field("refresh_token").as_deref(),
Some("RT-connected-1"),
"{} sent the wrong refresh token",
case.key
);
if case.basic {
assert!(
call.authorization
.as_deref()
.is_some_and(|header| header.starts_with("Basic ")),
"{} must authenticate with HTTP Basic: {:?}",
case.key,
call.authorization
);
assert_eq!(
call.field("client_secret"),
None,
"{} put the secret in the body as well as the header",
case.key
);
} else {
assert_eq!(
call.authorization, None,
"{} must not send a Basic header",
case.key
);
assert_eq!(
call.field("client_id").as_deref(),
Some(CLIENT_ID),
"{}",
case.key
);
assert_eq!(
call.field("client_secret").as_deref(),
Some(CLIENT_SECRET),
"{}",
case.key
);
}
kit.clock.advance_secs(3600);
kit.http.set_response(200, refresh_body(case.rotates));
kit.api
.access_token(&connection.id)
.await
.expect("the second refresh succeeds");
let second = kit.http.last_token_call().expect("a second token call");
let expected = if case.rotates {
"RT-refreshed-2"
} else {
"RT-connected-1"
};
assert_eq!(
second.field("refresh_token").as_deref(),
Some(expected),
"{} stored the wrong refresh token after a refresh",
case.key
);
kit.http.set_response(200, CONNECTED_BODY);
let refused = connect(&kit, "bob", case.key).await;
kit.clock.advance_secs(3600);
kit.http.set_response(400, INVALID_GRANT);
let error = kit
.api
.access_token(&refused.id)
.await
.expect_err("the provider refused the refresh");
kit.drain().await;
assert!(
matches!(error, ConnectionError::NeedsReconnect(_)),
"{}: {error:?}",
case.key
);
assert_eq!(
kit.api.get(&refused.id).await.expect("the row").status,
ConnectionStatus::NeedsReconnect,
"{}",
case.key
);
assert!(
kit.events.has("connections.needs_reconnect"),
"{} did not emit needs_reconnect",
case.key
);
}
}
}
#[pollster::test]
async fn a_refresh_without_an_expiry_keeps_the_recorded_one() {
for kit in fixture(&Spec::default()).kits {
kit.fresh();
let connection = connect(&kit, "alice", "x").await;
let recorded = connection.access_expires_at.clone();
assert!(recorded.is_some(), "the connect stated an expiry");
kit.clock.advance_secs(3600);
kit.http.set_response(200, REFRESHED_NO_EXPIRY);
let token = kit
.api
.access_token(&connection.id)
.await
.expect("the refresh succeeds");
assert_eq!(token.expose(), "AT-refreshed-2");
assert_eq!(
token.expires_at().map(str::to_owned),
recorded,
"the refresh wiped the recorded expiry"
);
assert_eq!(
kit.api
.get(&connection.id)
.await
.expect("the row")
.access_expires_at,
recorded,
"the stored row lost the expiry the connect recorded"
);
}
}
#[pollster::test]
async fn a_transport_failure_leaves_the_connection_untouched() {
for kit in fixture(&Spec::default()).kits {
kit.fresh();
let connection = connect(&kit, "alice", "x").await;
kit.http.reset();
kit.clock.advance_secs(3600);
kit.http.fail_transport("the provider timed out");
let error = kit
.api
.access_token(&connection.id)
.await
.expect_err("the provider is unreachable");
assert!(
matches!(error, ConnectionError::Provider(_)),
"a timeout is reported as an upstream failure: {error:?}"
);
assert_eq!(
kit.api.get(&connection.id).await.expect("the row").status,
ConnectionStatus::Active,
"a timeout must not mark the connection for a reconnect"
);
assert!(!kit.events.has("connections.needs_reconnect"));
kit.http.set_response(200, REFRESHED_BODY);
let token = kit
.api
.access_token(&connection.id)
.await
.expect("the retry succeeds");
assert_eq!(token.expose(), "AT-refreshed-2");
assert_eq!(
kit.http
.last_token_call()
.expect("a token call")
.field("refresh_token")
.as_deref(),
Some("RT-connected-1")
);
}
}