github-app-forge 0.1.1

Declarative GitHub App lifecycle management via Manifest flow
Documentation
//! Integration tests for github-app-forge — wiremock-backed fakes of GitHub's
//! Apps API exercise the client + sink + flow paths without touching real
//! GitHub. Tests are `#[serial]` because the API base URL is set via
//! GITHUB_API_URL env var (process-wide).
//!
//! What's covered:
//!   - manifest exchange (code → permanent credentials)
//!   - installation lookup (list installs, filter by owner)
//!   - rotate happy path (create new key → write sink → delete old key)
//!
//! Not covered (operator-only paths):
//!   - browser open + redirect capture (flow.rs serve_flow) — exercised by
//!     hand against pleme-io's real GitHub during rio activation
//!
//! The PEM at tests/fixtures/test-rsa-private.pem is a TEST key generated
//! solely for JWT signing in this suite. Never use it for anything real.

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");

/// Build an AppCredentials struct backed by the test 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,
    }
}

/// Set GITHUB_API_URL to the mock server, run the test, restore env.
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;
    // SAFETY: serial_test ensures no concurrent access to env vars in this suite
    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 {
        // Stage 1: create new key
        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;

        // Stage 2: list keys (returns the OLD key id 111 + new 999)
        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;

        // Stage 3: delete old key
        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");

        // Verify the sink received the new credentials
        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}"
        );
        // installation_id preserved across rotation
        assert!(
            written.contains("7777"),
            "sink should preserve installation_id 7777; got:\n{written}"
        );
    })
    .await;
}