use std::sync::Arc;
use std::sync::OnceLock;
use std::sync::atomic::{AtomicUsize, Ordering};
use axum::Router;
use axum::extract::State;
use axum::http::HeaderMap;
use axum::routing::{get, post};
use super::App;
use crate::auth::Permission;
use crate::auth::github;
use crate::error::Error;
use crate::namespace::Namespace;
fn key_pem() -> &'static str {
static PEM: OnceLock<String> = OnceLock::new();
PEM.get_or_init(|| {
use rsa::pkcs1::EncodeRsaPrivateKey;
let key =
rsa::RsaPrivateKey::new(&mut rand_core::OsRng, 2048).expect("a throwaway test key");
key.to_pkcs1_pem(rsa::pkcs1::LineEnding::LF)
.expect("the test key renders as PEM")
.to_string()
})
}
fn app_under_test(root: &tempfile::TempDir) -> App {
let key_file = root.path().join("app.pem");
std::fs::write(&key_file, key_pem()).unwrap();
App::load("41", &key_file, std::time::Duration::from_secs(10))
}
#[derive(Clone)]
struct Forge {
installed: bool,
private: bool,
minted: Arc<AtomicUsize>,
repo_auth: Arc<std::sync::Mutex<Vec<Option<String>>>>,
installation_auth: Arc<std::sync::Mutex<Vec<String>>>,
refuse_with: Option<axum::http::StatusCode>,
}
async fn forge(installed: bool, private: bool) -> (String, Forge) {
serve(state(installed, private, None)).await
}
async fn forge_refusing(status: axum::http::StatusCode) -> (String, Forge) {
serve(state(true, false, Some(status))).await
}
fn state(installed: bool, private: bool, refuse_with: Option<axum::http::StatusCode>) -> Forge {
Forge {
installed,
private,
minted: Arc::new(AtomicUsize::new(0)),
repo_auth: Arc::new(std::sync::Mutex::new(Vec::new())),
installation_auth: Arc::new(std::sync::Mutex::new(Vec::new())),
refuse_with,
}
}
async fn serve(state: Forge) -> (String, Forge) {
let router = Router::new()
.route(
"/repos/{org}/{repo}/installation",
get(
|State(forge): State<Forge>, headers: HeaderMap| async move {
let auth = headers
.get("authorization")
.map(|auth| auth.to_str().unwrap().to_owned());
assert!(
auth.as_deref()
.is_some_and(|auth| auth.starts_with("Bearer ")),
"the installation lookup has to identify as the App"
);
forge
.installation_auth
.lock()
.unwrap()
.push(auth.expect("asserted just above"));
if let Some(status) = forge.refuse_with {
return Err(status);
}
if forge.installed {
Ok(axum::Json(serde_json::json!({ "id": 7 })))
} else {
Err(axum::http::StatusCode::NOT_FOUND)
}
},
),
)
.route(
"/app/installations/7/access_tokens",
post(|State(forge): State<Forge>| async move {
forge.minted.fetch_add(1, Ordering::SeqCst);
axum::Json(serde_json::json!({
"token": "minted-installation-token",
"expires_at": "2035-01-01T00:00:00Z"
}))
}),
)
.route(
"/repos/{org}/{repo}",
get(
|State(forge): State<Forge>, headers: HeaderMap| async move {
forge.repo_auth.lock().unwrap().push(
headers
.get("authorization")
.map(|auth| auth.to_str().unwrap().to_owned()),
);
axum::Json(serde_json::json!({ "private": forge.private }))
},
),
)
.with_state(state.clone());
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("http://{}", listener.local_addr().unwrap());
tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
(url, state)
}
fn client() -> reqwest::Client {
crate::tls::install_crypto_provider();
reqwest::Client::new()
}
fn namespace() -> Namespace {
Namespace::new("FerrLabs", "Blastlands").unwrap()
}
#[tokio::test]
async fn a_private_repository_stays_refused_even_where_the_app_is_installed() {
let root = tempfile::tempdir().unwrap();
let (url, forge) = forge(true, true).await;
let app = app_under_test(&root);
let answer = github::public(&client(), &url, Some(&app), &namespace()).await;
assert!(matches!(answer, Err(Error::Unauthenticated)));
assert_eq!(
forge.repo_auth.lock().unwrap().as_slice(),
[Some("Bearer minted-installation-token".to_owned())],
"the question was asked as the App, and the visibility field refused it"
);
}
#[tokio::test]
async fn a_public_repository_is_granted_read_as_the_app() {
let root = tempfile::tempdir().unwrap();
let (url, _forge) = forge(true, false).await;
let app = app_under_test(&root);
let answer = github::public(&client(), &url, Some(&app), &namespace()).await;
assert!(matches!(answer, Ok(Permission::Read)));
}
#[tokio::test]
async fn no_installation_falls_back_to_the_anonymous_question() {
let root = tempfile::tempdir().unwrap();
let (url, forge) = forge(false, false).await;
let app = app_under_test(&root);
let answer = github::public(&client(), &url, Some(&app), &namespace()).await;
assert!(matches!(answer, Ok(Permission::Read)));
assert_eq!(
forge.repo_auth.lock().unwrap().as_slice(),
[None],
"with no installation the lookup must carry no credentials at all"
);
}
#[tokio::test]
async fn the_installation_token_is_minted_once_and_cached() {
let root = tempfile::tempdir().unwrap();
let (url, forge) = forge(true, false).await;
let app = app_under_test(&root);
for _ in 0..3 {
github::public(&client(), &url, Some(&app), &namespace())
.await
.unwrap();
}
assert_eq!(
forge.minted.load(Ordering::SeqCst),
1,
"a busy server exchanges once until expiry, not once a request"
);
}
#[tokio::test]
async fn the_app_jwt_is_signed_once_and_reused_across_namespaces() {
let root = tempfile::tempdir().unwrap();
let (url, forge) = forge(false, false).await;
let app = app_under_test(&root);
let first = Namespace::new("FerrLabs", "Alpha").unwrap();
let second = Namespace::new("Acme", "Beta").unwrap();
assert!(app.token(&client(), &url, &first).await.unwrap().is_none());
tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
assert!(app.token(&client(), &url, &second).await.unwrap().is_none());
let seen = forge.installation_auth.lock().unwrap().clone();
assert_eq!(seen.len(), 2, "each namespace is its own question");
assert_eq!(
seen[0], seen[1],
"a second signature a second later would carry a later iat"
);
}
#[tokio::test]
async fn a_namespace_the_app_is_not_installed_on_is_not_asked_about_twice() {
let root = tempfile::tempdir().unwrap();
let (url, forge) = forge(false, false).await;
let app = app_under_test(&root);
assert!(
app.token(&client(), &url, &namespace())
.await
.unwrap()
.is_none()
);
assert!(
app.token(&client(), &url, &namespace())
.await
.unwrap()
.is_none()
);
assert_eq!(
forge.installation_auth.lock().unwrap().len(),
1,
"the 404 is remembered, so the second request costs no forge call"
);
}
#[tokio::test]
async fn a_refusal_that_is_not_about_the_jwt_keeps_the_signature() {
let root = tempfile::tempdir().unwrap();
let (url, forge) = forge_refusing(axum::http::StatusCode::FORBIDDEN).await;
let app = app_under_test(&root);
assert!(app.token(&client(), &url, &namespace()).await.is_err());
tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
assert!(app.token(&client(), &url, &namespace()).await.is_err());
let seen = forge.installation_auth.lock().unwrap().clone();
assert_eq!(seen.len(), 2);
assert_eq!(
seen[0], seen[1],
"a spent budget is not a reason to reach the private key again"
);
}
#[tokio::test]
async fn a_jwt_the_forge_will_not_accept_is_signed_again() {
let root = tempfile::tempdir().unwrap();
let (url, forge) = forge_refusing(axum::http::StatusCode::UNAUTHORIZED).await;
let app = app_under_test(&root);
assert!(app.token(&client(), &url, &namespace()).await.is_err());
tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
assert!(app.token(&client(), &url, &namespace()).await.is_err());
let seen = forge.installation_auth.lock().unwrap().clone();
assert_eq!(seen.len(), 2);
assert_ne!(
seen[0], seen[1],
"a 401 is the one answer that means the JWT itself was refused"
);
}