use serde_json::json;
use serial_test::serial;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
const TEST_PEM: &str = include_str!("fixtures/test-rsa-private.pem");
fn test_creds(slug: &str, installation_id: Option<u64>) -> github_app_forge::client::AppCredentials {
use github_app_forge::client::AppCredentials;
AppCredentials {
id: 12345,
slug: slug.to_string(),
node_id: "MDM6QXBwMTIzNDU=".to_string(),
owner: json!({"id": 99, "login": "pleme-io"}),
name: "pleme-arc-test".to_string(),
html_url: "https://github.com/apps/pleme-arc-test".to_string(),
pem: TEST_PEM.to_string(),
webhook_secret: Some("hookhookhook".to_string()),
client_id: "Iv1.test".to_string(),
client_secret: "secret".to_string(),
installation_id,
}
}
async fn with_mock<F, Fut, R>(test_body: F) -> R
where
F: FnOnce(MockServer) -> Fut,
Fut: std::future::Future<Output = R>,
{
let server = MockServer::start().await;
unsafe {
std::env::set_var("GITHUB_API_URL", server.uri());
}
let result = test_body(server).await;
unsafe {
std::env::remove_var("GITHUB_API_URL");
}
result
}
#[tokio::test]
#[serial]
async fn manifest_exchange_returns_credentials() {
with_mock(|server| async move {
Mock::given(method("POST"))
.and(path("/app-manifests/abc123/conversions"))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"id": 42,
"slug": "pleme-arc-rio",
"node_id": "MDM6QXBwNDI=",
"owner": {"id": 99, "login": "pleme-io"},
"name": "pleme-arc-rio",
"html_url": "https://github.com/apps/pleme-arc-rio",
"pem": TEST_PEM,
"webhook_secret": null,
"client_id": "Iv1.deadbeef",
"client_secret": "shhh",
})))
.mount(&server)
.await;
let creds = github_app_forge::client::exchange_manifest_code("abc123")
.await
.expect("manifest exchange should succeed");
assert_eq!(creds.id, 42);
assert_eq!(creds.slug, "pleme-arc-rio");
assert_eq!(creds.pem, TEST_PEM);
assert!(creds.installation_id.is_none());
})
.await;
}
#[tokio::test]
#[serial]
async fn lookup_installation_id_filters_by_owner_case_insensitive() {
with_mock(|server| async move {
Mock::given(method("GET"))
.and(path("/app/installations"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([
{"id": 100, "account": {"login": "other-org"}},
{"id": 200, "account": {"login": "Pleme-IO"}},
{"id": 300, "account": {"login": "third"}},
])))
.mount(&server)
.await;
let creds = test_creds("pleme-arc-rio", None);
let id = github_app_forge::client::lookup_installation_id(&creds, "pleme-io")
.await
.expect("lookup should succeed");
assert_eq!(id, Some(200));
})
.await;
}
#[tokio::test]
#[serial]
async fn lookup_installation_returns_none_when_not_found() {
with_mock(|server| async move {
Mock::given(method("GET"))
.and(path("/app/installations"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([
{"id": 100, "account": {"login": "other"}},
])))
.mount(&server)
.await;
let creds = test_creds("pleme-arc-rio", None);
let id = github_app_forge::client::lookup_installation_id(&creds, "missing-org")
.await
.unwrap();
assert!(id.is_none());
})
.await;
}
#[tokio::test]
#[serial]
async fn rotate_creates_new_key_writes_sink_deletes_old() {
use std::io::Read;
with_mock(|server| async move {
Mock::given(method("POST"))
.and(path("/apps/pleme-arc-rio/keys"))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"id": 999,
"key": TEST_PEM,
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/apps/pleme-arc-rio/keys"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([
{"id": 111, "key": "(old)"},
{"id": 999, "key": "(new)"},
])))
.mount(&server)
.await;
Mock::given(method("DELETE"))
.and(path("/apps/pleme-arc-rio/keys/111"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let tmpdir = tempfile::tempdir().unwrap();
let sink_path = tmpdir.path().join("creds.yaml");
let sink_cfg = github_app_forge::manifest::SinkConfig::File {
path: sink_path.to_string_lossy().to_string(),
};
let creds = test_creds("pleme-arc-rio", Some(7777));
github_app_forge::client::rotate_private_key(&creds, &sink_cfg)
.await
.expect("rotate should succeed");
let mut written = String::new();
std::fs::File::open(&sink_path)
.unwrap()
.read_to_string(&mut written)
.unwrap();
assert!(
written.contains("pleme-arc-rio"),
"sink should contain slug; got:\n{written}"
);
assert!(
written.contains("7777"),
"sink should preserve installation_id 7777; got:\n{written}"
);
})
.await;
}