use reqwest_middleware::ClientWithMiddleware;
use crate::{DetectionState, DetectionStrategy};
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("insufficient permissions: {0}")]
InsufficientPermissions(&'static str),
#[error("HTTP request failed: {0}")]
Request(#[from] reqwest_middleware::Error),
}
#[derive(serde::Deserialize)]
struct TokenRequestResponse {
value: String,
}
pub(crate) struct GitHubActions {
client: ClientWithMiddleware,
}
impl DetectionStrategy for GitHubActions {
type Error = Error;
fn new(state: &DetectionState) -> Option<Self> {
std::env::var("GITHUB_ACTIONS")
.ok()
.filter(|v| v == "true")
.map(|_| GitHubActions {
client: state.client.clone(),
})
}
async fn detect(&self, audience: &str) -> Result<crate::IdToken, Self::Error> {
let url = std::env::var("ACTIONS_ID_TOKEN_REQUEST_URL")
.map_err(|_| Error::InsufficientPermissions("missing ACTIONS_ID_TOKEN_REQUEST_URL"))?;
let token = std::env::var("ACTIONS_ID_TOKEN_REQUEST_TOKEN").map_err(|_| {
Error::InsufficientPermissions("missing ACTIONS_ID_TOKEN_REQUEST_TOKEN")
})?;
let resp = self
.client
.get(&url)
.bearer_auth(token)
.query(&[("audience", audience)])
.send()
.await?
.error_for_status()
.map_err(reqwest_middleware::Error::Reqwest)?
.json::<TokenRequestResponse>()
.await
.map_err(reqwest_middleware::Error::Reqwest)?;
Ok(crate::IdToken(resp.value.into()))
}
}
#[cfg(test)]
mod tests {
use wiremock::{
Mock, MockServer,
matchers::{method, path},
};
use crate::{DetectionStrategy as _, tests::EnvScope};
use super::GitHubActions;
#[tokio::test]
#[cfg_attr(not(feature = "test-github-1p"), ignore)]
async fn test_1p_detection_ok() {
let _ = EnvScope::new();
let state = Default::default();
let detector = GitHubActions::new(&state).expect("should detect GitHub Actions");
let token = detector
.detect("test_1p_detection_ok")
.await
.expect("should fetch token");
assert!(token.reveal().starts_with("eyJ")); }
#[tokio::test]
#[cfg_attr(not(feature = "test-github-1p"), ignore)]
async fn test_1p_detection_missing_url() {
let mut scope = EnvScope::new();
scope.unsetenv("ACTIONS_ID_TOKEN_REQUEST_URL");
let state = Default::default();
let detector = GitHubActions::new(&state).expect("should detect GitHub Actions");
match detector.detect("test_1p_detection_missing_url").await {
Err(super::Error::InsufficientPermissions(what)) => {
assert_eq!(what, "missing ACTIONS_ID_TOKEN_REQUEST_URL")
}
_ => panic!("expected insufficient permissions error"),
}
}
#[tokio::test]
#[cfg_attr(not(feature = "test-github-1p"), ignore)]
async fn test_1p_detection_missing_token() {
let mut scope = EnvScope::new();
scope.unsetenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN");
let state = Default::default();
let detector = GitHubActions::new(&state).expect("should detect GitHub Actions");
match detector.detect("test_1p_detection_missing_token").await {
Err(super::Error::InsufficientPermissions(what)) => {
assert_eq!(what, "missing ACTIONS_ID_TOKEN_REQUEST_TOKEN")
}
_ => panic!("expected insufficient permissions error"),
}
}
#[tokio::test]
async fn test_not_detected() {
let mut scope = EnvScope::new();
scope.unsetenv("GITHUB_ACTIONS");
let state = Default::default();
assert!(GitHubActions::new(&state).is_none());
}
#[tokio::test]
async fn test_detected() {
let mut scope = EnvScope::new();
scope.setenv("GITHUB_ACTIONS", "true");
let state = Default::default();
assert!(GitHubActions::new(&state).is_some());
}
#[tokio::test]
async fn test_not_detected_wrong_value() {
for value in &["", "false", "TRUE", "1", "yes"] {
let mut scope = EnvScope::new();
scope.setenv("GITHUB_ACTIONS", value);
let state = Default::default();
assert!(GitHubActions::new(&state).is_none());
}
}
#[tokio::test]
async fn test_error_code() {
let mut scope = EnvScope::new();
let server = MockServer::start().await;
scope.setenv("GITHUB_ACTIONS", "true");
scope.setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "bogus");
scope.setenv("ACTIONS_ID_TOKEN_REQUEST_URL", &server.uri());
Mock::given(method("GET"))
.and(path("/"))
.respond_with(wiremock::ResponseTemplate::new(503))
.mount(&server)
.await;
let state = Default::default();
let detector = GitHubActions::new(&state).expect("should detect GitHub Actions");
assert!(matches!(
detector.detect("test_error_code").await,
Err(super::Error::Request(_))
));
}
#[tokio::test]
async fn test_invalid_response() {
let mut scope = EnvScope::new();
let server = MockServer::start().await;
scope.setenv("GITHUB_ACTIONS", "true");
scope.setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "bogus");
scope.setenv("ACTIONS_ID_TOKEN_REQUEST_URL", &server.uri());
Mock::given(method("GET"))
.and(path("/"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"bogus": "response"
})),
)
.mount(&server)
.await;
let state = Default::default();
let detector = GitHubActions::new(&state).expect("should detect GitHub Actions");
assert!(matches!(
detector.detect("test_invalid_response").await,
Err(super::Error::Request(_))
));
}
#[tokio::test]
async fn test_ok() {
let mut scope = EnvScope::new();
let server = MockServer::start().await;
scope.setenv("GITHUB_ACTIONS", "true");
scope.setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "bogus");
scope.setenv("ACTIONS_ID_TOKEN_REQUEST_URL", &server.uri());
Mock::given(method("GET"))
.and(path("/"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"value": "test-ok-token"
})),
)
.mount(&server)
.await;
let state = Default::default();
let detector = GitHubActions::new(&state).expect("should detect GitHub Actions");
let token = detector
.detect("test_ok")
.await
.expect("should fetch token");
assert_eq!(token.reveal(), "test-ok-token");
}
}