From fea64a105b565f76d3cc2cc34eb774cf0e1069ce Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Wed, 1 Apr 2026 09:48:29 -0400 Subject: [PATCH] Integrate twin-github for fabro-github tests Add the stripped twin-github test server to the workspace, wire it through fabro-test, and cover fabro-github's real HTTP auth and pull-request flows with twin-backed integration tests. This also refactors the GitHub helper entry points to take explicit base URLs so tests and callers share the same request path. --- .gitignore | 3 +- Cargo.lock | 21 + Cargo.toml | 3 +- lib/crates/fabro-github/Cargo.toml | 2 + lib/crates/fabro-github/src/lib.rs | 18 +- lib/crates/fabro-github/tests/integration.rs | 235 ++++ lib/crates/fabro-sandbox/src/daytona/mod.rs | 53 +- lib/crates/fabro-test/Cargo.toml | 1 + lib/crates/fabro-test/src/lib.rs | 19 + .../src/pipeline/pull_request.rs | 12 +- lib/crates/fabro-workflow/src/sandbox_git.rs | 8 +- .../tests/it/daytona_integration.rs | 19 +- test/twin/github/Cargo.toml | 30 + test/twin/github/src/auth.rs | 174 +++ test/twin/github/src/fixtures.rs | 556 +++++++++ test/twin/github/src/handlers/app.rs | 260 ++++ test/twin/github/src/handlers/branches.rs | 244 ++++ test/twin/github/src/handlers/git.rs | 407 +++++++ test/twin/github/src/handlers/graphql.rs | 1053 +++++++++++++++++ .../twin/github/src/handlers/installations.rs | 379 ++++++ test/twin/github/src/handlers/manifests.rs | 98 ++ test/twin/github/src/handlers/mod.rs | 74 ++ test/twin/github/src/handlers/pulls.rs | 582 +++++++++ test/twin/github/src/handlers/releases.rs | 75 ++ test/twin/github/src/lib.rs | 15 + test/twin/github/src/server.rs | 84 ++ test/twin/github/src/state.rs | 493 ++++++++ 27 files changed, 4877 insertions(+), 41 deletions(-) create mode 100644 lib/crates/fabro-github/tests/integration.rs create mode 100644 test/twin/github/Cargo.toml create mode 100644 test/twin/github/src/auth.rs create mode 100644 test/twin/github/src/fixtures.rs create mode 100644 test/twin/github/src/handlers/app.rs create mode 100644 test/twin/github/src/handlers/branches.rs create mode 100644 test/twin/github/src/handlers/git.rs create mode 100644 test/twin/github/src/handlers/graphql.rs create mode 100644 test/twin/github/src/handlers/installations.rs create mode 100644 test/twin/github/src/handlers/manifests.rs create mode 100644 test/twin/github/src/handlers/mod.rs create mode 100644 test/twin/github/src/handlers/pulls.rs create mode 100644 test/twin/github/src/handlers/releases.rs create mode 100644 test/twin/github/src/lib.rs create mode 100644 test/twin/github/src/server.rs create mode 100644 test/twin/github/src/state.rs diff --git a/.gitignore b/.gitignore index 37518cae5..54abbce01 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,5 @@ __pycache__ .ai/plans .ai/comments .ai/tmp -**/*.pending-snap \ No newline at end of file +.worktrees/ +**/*.pending-snap diff --git a/Cargo.lock b/Cargo.lock index dbd9e745d..9b0503ae8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1615,6 +1615,8 @@ version = "0.176.2" dependencies = [ "base64", "chrono", + "fabro-macros", + "fabro-test", "jsonwebtoken", "reqwest", "serde", @@ -1944,6 +1946,7 @@ dependencies = [ "reqwest", "tempfile", "tokio", + "twin-github", "twin-openai", ] @@ -6495,6 +6498,24 @@ dependencies = [ "utf-8", ] +[[package]] +name = "twin-github" +version = "0.176.2" +dependencies = [ + "axum", + "base64", + "chrono", + "jsonwebtoken", + "reqwest", + "serde", + "serde_json", + "tempfile", + "tokio", + "tracing", + "tracing-subscriber", + "uuid", +] + [[package]] name = "twin-openai" version = "0.176.2" diff --git a/Cargo.toml b/Cargo.toml index 5866ed20f..116d6883a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["lib/crates/*", "test/twin/openai"] +members = ["lib/crates/*", "test/twin/openai", "test/twin/github"] default-members = ["lib/crates/fabro-cli"] resolver = "2" @@ -59,6 +59,7 @@ hex = "0.4" insta = "1" fabro-test = { path = "lib/crates/fabro-test" } twin-openai = { path = "test/twin/openai" } +twin-github = { path = "test/twin/github" } tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] } futures-util = "0.3" daytona-sdk = { git = "https://github.com/brynary/daytona-sdk-rust", rev = "06033ca", package = "daytona-sdk" } diff --git a/lib/crates/fabro-github/Cargo.toml b/lib/crates/fabro-github/Cargo.toml index f98474f2c..830eb7eb0 100644 --- a/lib/crates/fabro-github/Cargo.toml +++ b/lib/crates/fabro-github/Cargo.toml @@ -23,4 +23,6 @@ tokio = { workspace = true } base64.workspace = true [dev-dependencies] +fabro-macros = { path = "../fabro-macros" } +fabro-test = { workspace = true } tokio = { workspace = true, features = ["test-util", "macros"] } diff --git a/lib/crates/fabro-github/src/lib.rs b/lib/crates/fabro-github/src/lib.rs index 58b5e0a64..70e36e9c2 100644 --- a/lib/crates/fabro-github/src/lib.rs +++ b/lib/crates/fabro-github/src/lib.rs @@ -403,6 +403,7 @@ pub async fn create_pull_request( title: &str, body: &str, draft: bool, + base_url: &str, ) -> Result { #[derive(Deserialize)] struct PullRequestResponse { @@ -414,9 +415,8 @@ pub async fn create_pull_request( let jwt = sign_app_jwt(&creds.app_id, &creds.private_key_pem)?; let client = reqwest::Client::new(); - let base_url = github_api_base_url(); let token = - create_installation_access_token_for_pr(&client, &jwt, owner, repo, &base_url).await?; + create_installation_access_token_for_pr(&client, &jwt, owner, repo, base_url).await?; tracing::debug!(title = %title, head = %head, base = %base, draft, "Creating pull request"); @@ -502,13 +502,13 @@ pub async fn enable_auto_merge( repo: &str, pr_node_id: &str, merge_method: AutoMergeMethod, + base_url: &str, ) -> Result<(), String> { let jwt = sign_app_jwt(&creds.app_id, &creds.private_key_pem)?; let client = reqwest::Client::new(); - let base_url = github_api_base_url(); let token = - create_installation_access_token_for_pr(&client, &jwt, owner, repo, &base_url).await?; + create_installation_access_token_for_pr(&client, &jwt, owner, repo, base_url).await?; let query = format!( r#"mutation {{ @@ -735,12 +735,12 @@ pub async fn resolve_clone_credentials( creds: &GitHubAppCredentials, owner: &str, repo: &str, + base_url: &str, ) -> Result<(Option, Option), String> { let jwt = sign_app_jwt(&creds.app_id, &creds.private_key_pem)?; let client = reqwest::Client::new(); - let base_url = github_api_base_url(); - let token = create_installation_access_token(&client, &jwt, owner, repo, &base_url).await?; + let token = create_installation_access_token(&client, &jwt, owner, repo, base_url).await?; Ok((Some("x-access-token".to_string()), Some(token))) } @@ -755,14 +755,14 @@ pub fn embed_token_in_url(url: &str, token: &str) -> String { /// Resolve an authenticated HTTPS URL for a GitHub repository. /// /// Parses owner/repo from the URL, obtains a fresh installation access token, -/// and returns the URL with embedded credentials. Returns the original URL -/// unchanged if it's not a GitHub URL. +/// and returns the URL with embedded credentials. pub async fn resolve_authenticated_url( creds: &GitHubAppCredentials, url: &str, + base_url: &str, ) -> Result { let (owner, repo) = parse_github_owner_repo(url)?; - let (_username, password) = resolve_clone_credentials(creds, &owner, &repo).await?; + let (_username, password) = resolve_clone_credentials(creds, &owner, &repo, base_url).await?; match password { Some(token) => Ok(embed_token_in_url(url, &token)), None => Ok(url.to_string()), diff --git a/lib/crates/fabro-github/tests/integration.rs b/lib/crates/fabro-github/tests/integration.rs new file mode 100644 index 000000000..f26d83971 --- /dev/null +++ b/lib/crates/fabro-github/tests/integration.rs @@ -0,0 +1,235 @@ +use fabro_github::{ + AutoMergeMethod, GitHubAppCredentials, close_pull_request, + create_installation_access_token_for_pr, create_pull_request, enable_auto_merge, + get_pull_request, merge_pull_request, resolve_authenticated_url, sign_app_jwt, +}; +use fabro_test::{GitHubAppConfig, GitHubAppState, TwinGitHub}; + +const TEST_RSA_KEY: &str = include_str!("../src/testdata/rsa_private.pem"); + +fn github_credentials() -> GitHubAppCredentials { + GitHubAppCredentials { + app_id: "42".to_string(), + private_key_pem: TEST_RSA_KEY.to_string(), + } +} + +fn standard_app_state() -> GitHubAppState { + let mut state = GitHubAppState::new(); + state.register_app(GitHubAppConfig { + app_id: "42".into(), + slug: "test-app".into(), + owner_login: "acme".into(), + public: true, + private_key_pem: TEST_RSA_KEY.into(), + webhook_secret: None, + }); + state.add_installation("42", "acme", vec!["widgets".into()], false); + state.add_repository( + "acme", + "widgets", + vec!["main".into(), "feature".into()], + false, + ); + state +} + +#[fabro_macros::e2e_test(twin)] +async fn create_and_get_pull_request() { + let twin = TwinGitHub::start(standard_app_state()).await; + let creds = github_credentials(); + + let created = create_pull_request( + &creds, + "acme", + "widgets", + "main", + "feature", + "Add widgets", + "PR body", + false, + &twin.base_url, + ) + .await + .unwrap(); + + let pr = get_pull_request(&creds, "acme", "widgets", created.number, &twin.base_url) + .await + .unwrap(); + + assert_eq!(pr.title, "Add widgets"); + assert_eq!(pr.state, "open"); + assert_eq!(pr.head.ref_name, "feature"); + assert_eq!(pr.base.ref_name, "main"); + + twin.shutdown().await; +} + +#[fabro_macros::e2e_test(twin)] +async fn create_merge_and_verify_state() { + let twin = TwinGitHub::start(standard_app_state()).await; + let creds = github_credentials(); + + let created = create_pull_request( + &creds, + "acme", + "widgets", + "main", + "feature", + "Merge me", + "PR body", + false, + &twin.base_url, + ) + .await + .unwrap(); + + merge_pull_request( + &creds, + "acme", + "widgets", + created.number, + "squash", + &twin.base_url, + ) + .await + .unwrap(); + + let pr = get_pull_request(&creds, "acme", "widgets", created.number, &twin.base_url) + .await + .unwrap(); + + assert_eq!(pr.state, "closed"); + assert_eq!(pr.mergeable, Some(false)); + + twin.shutdown().await; +} + +#[fabro_macros::e2e_test(twin)] +async fn create_close_and_verify_state() { + let twin = TwinGitHub::start(standard_app_state()).await; + let creds = github_credentials(); + + let created = create_pull_request( + &creds, + "acme", + "widgets", + "main", + "feature", + "Close me", + "PR body", + false, + &twin.base_url, + ) + .await + .unwrap(); + + close_pull_request(&creds, "acme", "widgets", created.number, &twin.base_url) + .await + .unwrap(); + + let pr = get_pull_request(&creds, "acme", "widgets", created.number, &twin.base_url) + .await + .unwrap(); + + assert_eq!(pr.state, "closed"); + + twin.shutdown().await; +} + +#[fabro_macros::e2e_test(twin)] +async fn enable_auto_merge_persists() { + let twin = TwinGitHub::start(standard_app_state()).await; + let creds = github_credentials(); + + let created = create_pull_request( + &creds, + "acme", + "widgets", + "main", + "feature", + "Auto merge me", + "PR body", + false, + &twin.base_url, + ) + .await + .unwrap(); + + enable_auto_merge( + &creds, + "acme", + "widgets", + &created.node_id, + AutoMergeMethod::Squash, + &twin.base_url, + ) + .await + .unwrap(); + + let jwt = sign_app_jwt(&creds.app_id, &creds.private_key_pem).unwrap(); + let token = create_installation_access_token_for_pr( + &reqwest::Client::new(), + &jwt, + "acme", + "widgets", + &twin.base_url, + ) + .await + .unwrap(); + + let detail: serde_json::Value = reqwest::Client::new() + .get(format!( + "{}/repos/acme/widgets/pulls/{}", + twin.base_url, created.number + )) + .header("Authorization", format!("Bearer {token}")) + .header("Accept", "application/vnd.github+json") + .header("User-Agent", "fabro") + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + + assert_eq!( + detail["auto_merge"]["merge_method"].as_str(), + Some("SQUASH") + ); + + twin.shutdown().await; +} + +#[fabro_macros::e2e_test(twin)] +async fn resolve_authenticated_url_embeds_token() { + let twin = TwinGitHub::start(standard_app_state()).await; + let creds = github_credentials(); + + let url = resolve_authenticated_url( + &creds, + "https://github.com/acme/widgets.git", + &twin.base_url, + ) + .await + .unwrap(); + + assert!(url.starts_with("https://x-access-token:ghs_")); + assert!(url.contains("github.com/acme/widgets.git")); + + twin.shutdown().await; +} + +#[fabro_macros::e2e_test(twin)] +async fn resolve_authenticated_url_errors_on_non_github_url() { + let twin = TwinGitHub::start(standard_app_state()).await; + let creds = github_credentials(); + + let error = resolve_authenticated_url(&creds, "https://gitlab.com/foo/bar", &twin.base_url) + .await + .unwrap_err(); + + assert!(error.contains("Not a GitHub HTTPS URL")); + + twin.shutdown().await; +} diff --git a/lib/crates/fabro-sandbox/src/daytona/mod.rs b/lib/crates/fabro-sandbox/src/daytona/mod.rs index a7655b4c6..9edbf86f4 100644 --- a/lib/crates/fabro-sandbox/src/daytona/mod.rs +++ b/lib/crates/fabro-sandbox/src/daytona/mod.rs @@ -508,26 +508,29 @@ impl Sandbox for DaytonaSandbox { }); err })?; - fabro_github::resolve_clone_credentials(creds, &owner, &repo) - .await - .map_err(|e| { - let err = format!( - "Failed to get GitHub App credentials for clone: {e}" - ); - self.emit(SandboxEvent::GitCloneFailed { - url: url.clone(), - error: err.clone(), - }); - let duration_ms = - u64::try_from(init_start.elapsed().as_millis()) - .unwrap_or(u64::MAX); - self.emit(SandboxEvent::InitializeFailed { - provider: "daytona".into(), - error: err.clone(), - duration_ms, - }); - err - })? + fabro_github::resolve_clone_credentials( + creds, + &owner, + &repo, + &fabro_github::github_api_base_url(), + ) + .await + .map_err(|e| { + let err = + format!("Failed to get GitHub App credentials for clone: {e}"); + self.emit(SandboxEvent::GitCloneFailed { + url: url.clone(), + error: err.clone(), + }); + let duration_ms = u64::try_from(init_start.elapsed().as_millis()) + .unwrap_or(u64::MAX); + self.emit(SandboxEvent::InitializeFailed { + provider: "daytona".into(), + error: err.clone(), + duration_ms, + }); + err + })? } None => (None, None), }; @@ -789,9 +792,13 @@ impl Sandbox for DaytonaSandbox { return Ok(()); }; - let auth_url = fabro_github::resolve_authenticated_url(creds, origin_url) - .await - .map_err(|e| format!("Failed to refresh GitHub App token: {e}"))?; + let auth_url = fabro_github::resolve_authenticated_url( + creds, + origin_url, + &fabro_github::github_api_base_url(), + ) + .await + .map_err(|e| format!("Failed to refresh GitHub App token: {e}"))?; let cmd = format!( "git -c maintenance.auto=0 remote set-url origin {}", diff --git a/lib/crates/fabro-test/Cargo.toml b/lib/crates/fabro-test/Cargo.toml index 3ecc7a7fb..7d2891ad0 100644 --- a/lib/crates/fabro-test/Cargo.toml +++ b/lib/crates/fabro-test/Cargo.toml @@ -21,3 +21,4 @@ reqwest = { workspace = true } tempfile = "3" tokio = { workspace = true } twin-openai = { workspace = true } +twin-github = { workspace = true } diff --git a/lib/crates/fabro-test/src/lib.rs b/lib/crates/fabro-test/src/lib.rs index 2e394eba6..c65d15e01 100644 --- a/lib/crates/fabro-test/src/lib.rs +++ b/lib/crates/fabro-test/src/lib.rs @@ -489,6 +489,8 @@ impl TestContext { use tokio::net::TcpListener as TokioTcpListener; use tokio::sync::OnceCell; use tokio::time; +pub use twin_github::AppState as GitHubAppState; +pub use twin_github::state::AppConfig as GitHubAppConfig; use twin_openai::config::Config as TwinConfig; /// A shared twin-openai server instance. @@ -497,6 +499,23 @@ pub struct TwinOpenAi { pub base_url: String, } +pub struct TwinGitHub { + pub base_url: String, + server: twin_github::TestServer, +} + +impl TwinGitHub { + pub async fn start(state: twin_github::AppState) -> Self { + let server = twin_github::TestServer::start(state).await; + let base_url = server.url().to_string(); + Self { base_url, server } + } + + pub async fn shutdown(self) { + self.server.shutdown().await; + } +} + static TWIN_OPENAI: OnceCell = OnceCell::const_new(); /// Returns a shared twin-openai server, starting it on first call. diff --git a/lib/crates/fabro-workflow/src/pipeline/pull_request.rs b/lib/crates/fabro-workflow/src/pipeline/pull_request.rs index 9a3e2c739..99231787e 100644 --- a/lib/crates/fabro-workflow/src/pipeline/pull_request.rs +++ b/lib/crates/fabro-workflow/src/pipeline/pull_request.rs @@ -485,6 +485,7 @@ pub async fn maybe_open_pull_request( &title, &body, draft, + &github_app::github_api_base_url(), ) .await?; @@ -496,8 +497,15 @@ pub async fn maybe_open_pull_request( MergeStrategy::Merge => github_app::AutoMergeMethod::Merge, MergeStrategy::Rebase => github_app::AutoMergeMethod::Rebase, }; - match github_app::enable_auto_merge(creds, &owner, &repo, &created.node_id, merge_method) - .await + match github_app::enable_auto_merge( + creds, + &owner, + &repo, + &created.node_id, + merge_method, + &github_app::github_api_base_url(), + ) + .await { Ok(()) => { info!(pr_number = created.number, "Auto-merge enabled"); diff --git a/lib/crates/fabro-workflow/src/sandbox_git.rs b/lib/crates/fabro-workflow/src/sandbox_git.rs index 05762a4ef..a2de0ed85 100644 --- a/lib/crates/fabro-workflow/src/sandbox_git.rs +++ b/lib/crates/fabro-workflow/src/sandbox_git.rs @@ -147,7 +147,13 @@ pub async fn git_push_host( let https_url = fabro_github::ssh_url_to_https(&origin_url); let push_url = if let Some(creds) = github_app { - match fabro_github::resolve_authenticated_url(creds, &https_url).await { + match fabro_github::resolve_authenticated_url( + creds, + &https_url, + &fabro_github::github_api_base_url(), + ) + .await + { Ok(url) => url, Err(e) => { tracing::warn!(error = %e, label, "Failed to get token for push"); diff --git a/lib/crates/fabro-workflow/tests/it/daytona_integration.rs b/lib/crates/fabro-workflow/tests/it/daytona_integration.rs index 59101951d..8f63f2e71 100644 --- a/lib/crates/fabro-workflow/tests/it/daytona_integration.rs +++ b/lib/crates/fabro-workflow/tests/it/daytona_integration.rs @@ -1395,9 +1395,14 @@ async fn daytona_clone_public_repo_gets_credentials() { let creds = load_github_app_credentials(); // Directly test resolve_clone_credentials against a repo in an org where the app is installed - let (username, password) = fabro_github::resolve_clone_credentials(&creds, "fabro-sh", "fabro") - .await - .unwrap(); + let (username, password) = fabro_github::resolve_clone_credentials( + &creds, + "fabro-sh", + "fabro", + &fabro_github::github_api_base_url(), + ) + .await + .unwrap(); assert_eq!( username.as_deref(), @@ -1416,7 +1421,13 @@ async fn daytona_clone_public_repo_gets_credentials() { async fn daytona_iat_not_installed_gives_clear_error() { let creds = load_github_app_credentials(); - let result = fabro_github::resolve_clone_credentials(&creds, "torvalds", "linux").await; + let result = fabro_github::resolve_clone_credentials( + &creds, + "torvalds", + "linux", + &fabro_github::github_api_base_url(), + ) + .await; assert!( result.is_err(), diff --git a/test/twin/github/Cargo.toml b/test/twin/github/Cargo.toml new file mode 100644 index 000000000..bd7bcd2a7 --- /dev/null +++ b/test/twin/github/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "twin-github" +edition.workspace = true +version.workspace = true +publish = false +license.workspace = true +description = "Fake GitHub API server for local black-box testing" + +[lib] +doctest = false + +[lints] +workspace = true + +[dependencies] +axum = { workspace = true } +base64 = { workspace = true } +chrono = { workspace = true } +jsonwebtoken = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tempfile = "3" +tokio = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +uuid = { workspace = true } + +[dev-dependencies] +reqwest = { workspace = true } +tokio = { workspace = true, features = ["test-util", "macros"] } diff --git a/test/twin/github/src/auth.rs b/test/twin/github/src/auth.rs new file mode 100644 index 000000000..a8e4c8370 --- /dev/null +++ b/test/twin/github/src/auth.rs @@ -0,0 +1,174 @@ +use axum::http::HeaderMap; + +use crate::state::{AppState, PermissionLevel, TokenInfo, TokenPermission}; + +/// Verify a GitHub App JWT (RS256) and return the `iss` claim (app_id). +/// +/// Accepts a **public** key PEM. The caller obtains this from `RegisteredApp::public_key_pem`, +/// which is derived from the private key during `AppState::register_app`. +pub fn verify_app_jwt(jwt: &str, public_key_pem: &str) -> Result { + use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode}; + use serde::Deserialize; + + #[derive(Deserialize)] + struct Claims { + iss: String, + } + + let key = DecodingKey::from_rsa_pem(public_key_pem.as_bytes()) + .map_err(|e| format!("Invalid RSA public key: {e}"))?; + + let mut validation = Validation::new(Algorithm::RS256); + validation.validate_exp = true; + validation.set_required_spec_claims(&["iss", "iat", "exp"]); + + let data = decode::(jwt, &key, &validation) + .map_err(|e| format!("JWT verification failed: {e}"))?; + + Ok(data.claims.iss) +} + +/// Extract Bearer token from Authorization header. +pub fn extract_bearer_token(headers: &HeaderMap) -> Option { + headers + .get("Authorization") + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.strip_prefix("Bearer ")) + .map(|s| s.to_string()) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BearerTokenError { + Missing, + Invalid, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InstallationTokenAccessError { + RepoNotAccessible, + PermissionDenied, +} + +pub enum GraphqlActor { + InstallationToken(TokenInfo), + AppJwt, +} + +pub fn verify_any_app_jwt(state: &AppState, jwt: &str) -> bool { + state + .apps + .values() + .any(|app| verify_app_jwt(jwt, &app.public_key_pem).is_ok()) +} + +pub fn authorize_installation_token( + headers: &HeaderMap, + state: &AppState, +) -> Result { + let token = extract_bearer_token(headers).ok_or(BearerTokenError::Missing)?; + state + .validate_token(&token) + .cloned() + .ok_or(BearerTokenError::Invalid) +} + +pub fn authorize_graphql_actor( + headers: &HeaderMap, + state: &AppState, +) -> Result { + let token = extract_bearer_token(headers).ok_or(BearerTokenError::Missing)?; + if let Some(token_info) = state.validate_token(&token) { + return Ok(GraphqlActor::InstallationToken(token_info.clone())); + } + if verify_any_app_jwt(state, &token) { + return Ok(GraphqlActor::AppJwt); + } + Err(BearerTokenError::Invalid) +} + +pub fn ensure_repo_permission( + token: &TokenInfo, + repo: &str, + permission: TokenPermission, + required: PermissionLevel, +) -> Result<(), InstallationTokenAccessError> { + if !token.allows_repo(repo) { + return Err(InstallationTokenAccessError::RepoNotAccessible); + } + if !token.allows(permission, required) { + return Err(InstallationTokenAccessError::PermissionDenied); + } + Ok(()) +} + +pub fn ensure_permission( + token: &TokenInfo, + permission: TokenPermission, + required: PermissionLevel, +) -> Result<(), InstallationTokenAccessError> { + if !token.allows(permission, required) { + return Err(InstallationTokenAccessError::PermissionDenied); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::state::derive_public_key_pem; + + fn test_rsa_key() -> String { + use std::process::Command; + let output = Command::new("openssl") + .args([ + "genpkey", + "-algorithm", + "RSA", + "-pkeyopt", + "rsa_keygen_bits:2048", + ]) + .output() + .expect("openssl should be available"); + assert!(output.status.success()); + String::from_utf8(output.stdout).unwrap() + } + + fn sign_test_jwt(app_id: &str, private_key_pem: &str) -> String { + use jsonwebtoken::{Algorithm, EncodingKey, Header, encode}; + use serde::Serialize; + + #[derive(Serialize)] + struct Claims { + iss: String, + iat: i64, + exp: i64, + } + + let now = chrono::Utc::now().timestamp(); + let claims = Claims { + iss: app_id.to_string(), + iat: now - 60, + exp: now + 600, + }; + let key = EncodingKey::from_rsa_pem(private_key_pem.as_bytes()).unwrap(); + encode(&Header::new(Algorithm::RS256), &claims, &key).unwrap() + } + + #[test] + fn verify_valid_jwt() { + let private_pem = test_rsa_key(); + let public_pem = derive_public_key_pem(&private_pem); + let jwt = sign_test_jwt("12345", &private_pem); + let result = verify_app_jwt(&jwt, &public_pem); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "12345"); + } + + #[test] + fn reject_invalid_jwt() { + let private_pem = test_rsa_key(); + let public_pem = derive_public_key_pem(&private_pem); + let result = verify_app_jwt("invalid.jwt.token", &public_pem); + assert!(result.is_err()); + } +} diff --git a/test/twin/github/src/fixtures.rs b/test/twin/github/src/fixtures.rs new file mode 100644 index 000000000..c86753e98 --- /dev/null +++ b/test/twin/github/src/fixtures.rs @@ -0,0 +1,556 @@ +use std::collections::HashMap; +use std::fs; +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::path::Path; + +use serde::Deserialize; + +use crate::state::{ + AppConfig, AppState, Comment, Installation, IssueContent, ManifestConversion, OwnerType, + Project, ProjectItem, PullRequest, Release, Repository, StatusOption, TokenInfo, WebhookConfig, +}; + +#[derive(Debug, Clone, Deserialize, Default)] +pub struct FixtureState { + #[serde(default)] + pub apps: Vec, + #[serde(default)] + pub installations: Vec, + #[serde(default)] + pub repositories: Vec, + #[serde(default)] + pub pull_requests: Vec, + #[serde(default)] + pub active_tokens: Vec, + #[serde(default)] + pub projects: Vec, + #[serde(default)] + pub releases: Vec, + #[serde(default)] + pub manifest_conversions: Vec, + #[serde(default)] + pub comments: Vec, + #[serde(default)] + pub webhook_config: FixtureWebhookConfig, + pub next_installation_id: Option, + pub next_pr_number: Option, + pub viewer_id: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct FixtureApp { + pub app_id: String, + pub slug: String, + pub owner_login: String, + pub public: bool, + pub private_key_pem: String, + pub webhook_secret: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct FixtureInstallation { + pub id: u64, + pub app_id: String, + pub owner: String, + #[serde(default)] + pub repositories: Vec, + #[serde(default)] + pub suspended: bool, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct FixtureRepository { + pub owner: String, + pub name: String, + #[serde(default)] + pub branches: Vec, + pub default_branch: Option, + #[serde(default)] + pub private: bool, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct FixturePullRequest { + pub owner: String, + pub repo: String, + #[serde(flatten)] + pub pull_request: PullRequest, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct FixtureActiveToken { + pub token: String, + pub app_id: String, + pub installation_id: u64, + #[serde(default)] + pub repositories: Vec, + #[serde(default = "empty_json_object")] + pub permissions: serde_json::Value, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct FixtureProject { + pub node_id: String, + pub number: u64, + pub owner: String, + pub owner_type: FixtureOwnerType, + pub status_field_id: String, + #[serde(default)] + pub status_options: Vec, + #[serde(default)] + pub items: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum FixtureOwnerType { + Organization, + User, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct FixtureStatusOption { + pub id: String, + pub name: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct FixtureProjectItem { + pub id: String, + pub status: String, + pub content: FixtureIssueContent, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct FixtureIssueContent { + pub id: String, + pub number: u64, + pub title: String, + pub body: String, + pub url: String, + pub created_at: String, + pub updated_at: String, + #[serde(default)] + pub assignee_ids: Vec, + #[serde(default)] + pub labels: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct FixtureRelease { + pub owner: String, + pub repo: String, + pub tag_name: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct FixtureManifestConversion { + pub code: String, + pub app_id: i64, + pub slug: String, + pub client_id: String, + pub client_secret: String, + pub webhook_secret: Option, + pub pem: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct FixtureComment { + pub issue_node_id: String, + pub body: String, +} + +#[derive(Debug, Clone, Deserialize, Default)] +pub struct FixtureWebhookConfig { + pub url: Option, + pub content_type: Option, +} + +impl FixtureState { + pub fn load_from_path(path: impl AsRef) -> Result { + let path = path.as_ref(); + let contents = fs::read_to_string(path) + .map_err(|err| format!("failed to read fixture {}: {err}", path.display()))?; + serde_json::from_str(&contents) + .map_err(|err| format!("failed to parse fixture {}: {err}", path.display())) + } + + pub fn into_app_state(self) -> Result { + let mut state = AppState::new(); + + for app in self.apps { + let app_id = app.app_id.clone(); + let config = AppConfig { + app_id: app.app_id, + slug: app.slug, + owner_login: app.owner_login, + public: app.public, + private_key_pem: app.private_key_pem, + webhook_secret: app.webhook_secret, + }; + + catch_unwind(AssertUnwindSafe(|| state.register_app(config))) + .map_err(|_| format!("invalid RSA private key PEM for fixture app {app_id}"))?; + } + + state.installations = self + .installations + .into_iter() + .map(|installation| Installation { + id: installation.id, + app_id: installation.app_id, + owner: installation.owner, + repositories: installation.repositories, + suspended: installation.suspended, + }) + .collect(); + + state.repositories = self + .repositories + .into_iter() + .map(|repository| Repository { + owner: repository.owner, + name: repository.name, + branches: repository.branches, + default_branch: repository + .default_branch + .unwrap_or_else(|| "main".to_string()), + private: repository.private, + git_dir: None, + }) + .collect(); + + for pull_request in self.pull_requests { + state + .pull_requests + .entry((pull_request.owner, pull_request.repo)) + .or_default() + .push(pull_request.pull_request); + } + + state.active_tokens = self + .active_tokens + .into_iter() + .map(|token| { + ( + token.token, + TokenInfo { + app_id: token.app_id, + installation_id: token.installation_id, + repositories: token.repositories, + permissions: token.permissions, + }, + ) + }) + .collect(); + + state.projects = self + .projects + .into_iter() + .map(|project| Project { + node_id: project.node_id, + number: project.number, + owner: project.owner, + owner_type: match project.owner_type { + FixtureOwnerType::Organization => OwnerType::Organization, + FixtureOwnerType::User => OwnerType::User, + }, + status_field_id: project.status_field_id, + status_options: project + .status_options + .into_iter() + .map(|option| StatusOption { + id: option.id, + name: option.name, + }) + .collect(), + items: project + .items + .into_iter() + .map(|item| ProjectItem { + id: item.id, + status: item.status, + content: IssueContent { + id: item.content.id, + number: item.content.number, + title: item.content.title, + body: item.content.body, + url: item.content.url, + created_at: item.content.created_at, + updated_at: item.content.updated_at, + assignee_ids: item.content.assignee_ids, + labels: item.content.labels, + }, + }) + .collect(), + }) + .collect(); + + state.releases = self + .releases + .into_iter() + .map(|release| { + ( + (release.owner, release.repo), + Release { + tag_name: release.tag_name, + }, + ) + }) + .collect::>(); + + state.manifest_conversions = self + .manifest_conversions + .into_iter() + .map(|conversion| { + let code = conversion.code.clone(); + ( + code, + ManifestConversion { + code: conversion.code, + app_id: conversion.app_id, + slug: conversion.slug, + client_id: conversion.client_id, + client_secret: conversion.client_secret, + webhook_secret: conversion.webhook_secret, + pem: conversion.pem, + }, + ) + }) + .collect::>(); + + state.comments = self + .comments + .into_iter() + .map(|comment| Comment { + issue_node_id: comment.issue_node_id, + body: comment.body, + }) + .collect(); + + state.webhook_config = WebhookConfig { + url: self.webhook_config.url, + content_type: self.webhook_config.content_type, + }; + + state.next_installation_id = self + .next_installation_id + .unwrap_or_else(|| next_installation_id(&state.installations)); + state.next_pr_number = self + .next_pr_number + .unwrap_or_else(|| next_pr_number(&state.pull_requests)); + + if let Some(viewer_id) = self.viewer_id { + state.viewer_id = viewer_id; + } + + Ok(state) + } +} + +fn empty_json_object() -> serde_json::Value { + serde_json::json!({}) +} + +fn next_installation_id(installations: &[Installation]) -> u64 { + installations + .iter() + .map(|installation| installation.id) + .max() + .unwrap_or(0) + + 1 +} + +fn next_pr_number(pull_requests: &HashMap<(String, String), Vec>) -> u64 { + pull_requests + .values() + .flat_map(|prs| prs.iter().map(|pr| pr.number)) + .max() + .unwrap_or(0) + + 1 +} + +#[cfg(test)] +fn test_rsa_key() -> String { + use std::process::Command; + + let output = Command::new("openssl") + .args([ + "genpkey", + "-algorithm", + "RSA", + "-pkeyopt", + "rsa_keygen_bits:2048", + ]) + .output() + .expect("openssl should be available"); + assert!(output.status.success()); + String::from_utf8(output.stdout).unwrap() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fixture_state_loads_full_server_state() { + let private_key_pem = test_rsa_key().replace('\n', "\\n"); + let fixture_json = r#"{ + "apps": [{ + "app_id": "100", + "slug": "ops-app", + "owner_login": "acme", + "public": true, + "private_key_pem": "__PRIVATE_KEY__", + "webhook_secret": "whsec" + }], + "installations": [{ + "id": 7, + "app_id": "100", + "owner": "acme", + "repositories": ["widgets"], + "suspended": false + }], + "repositories": [{ + "owner": "acme", + "name": "widgets", + "branches": ["main", "feature"], + "default_branch": "main" + }], + "pull_requests": [{ + "owner": "acme", + "repo": "widgets", + "number": 1, + "node_id": "PR_1", + "title": "Ship it", + "body": "Ready", + "state": "open", + "draft": false, + "mergeable": true, + "additions": 10, + "deletions": 5, + "changed_files": 2, + "html_url": "https://github.com/acme/widgets/pull/1", + "user_login": "bot", + "head_ref": "feature", + "base_ref": "main", + "created_at": "2026-03-27T00:00:00Z", + "updated_at": "2026-03-27T00:00:00Z", + "auto_merge": null + }], + "active_tokens": [{ + "token": "ghs_exampletoken", + "app_id": "100", + "installation_id": 7, + "repositories": ["widgets"], + "permissions": {"contents": "write"} + }], + "projects": [{ + "node_id": "PVT_kwDOB", + "number": 3, + "owner": "acme", + "owner_type": "organization", + "status_field_id": "PVTSSF_1", + "status_options": [{ + "id": "opt_todo", + "name": "Todo" + }], + "items": [{ + "id": "PVTI_1", + "status": "Todo", + "content": { + "id": "ISSUE_1", + "number": 42, + "title": "Track launch", + "body": "Ship it", + "url": "https://github.com/acme/widgets/issues/42", + "created_at": "2026-03-27T00:00:00Z", + "updated_at": "2026-03-27T00:00:00Z", + "assignee_ids": ["U_1"], + "labels": ["priority"] + } + }] + }], + "releases": [{ + "owner": "acme", + "repo": "widgets", + "tag_name": "v1.2.3" + }], + "manifest_conversions": [{ + "code": "manifest-code", + "app_id": 100, + "slug": "ops-app-dev", + "client_id": "Iv1.123", + "client_secret": "manifest-secret", + "webhook_secret": "manifest-whsec", + "pem": "manifest pem" + }], + "comments": [{ + "issue_node_id": "ISSUE_1", + "body": "Looks good" + }], + "webhook_config": { + "url": "https://example.com/webhooks/github", + "content_type": "json" + }, + "next_installation_id": 99, + "next_pr_number": 77, + "viewer_id": "U_seeded" + }"# + .replace("__PRIVATE_KEY__", &private_key_pem); + let fixture: FixtureState = serde_json::from_str(&fixture_json).unwrap(); + + let state = fixture.into_app_state().unwrap(); + + assert_eq!(state.apps["100"].config.slug, "ops-app"); + assert_eq!(state.installations.len(), 1); + assert_eq!(state.repositories.len(), 1); + assert_eq!( + state.pull_requests[&("acme".into(), "widgets".into())].len(), + 1 + ); + assert_eq!(state.active_tokens.len(), 1); + assert_eq!(state.projects.len(), 1); + assert_eq!( + state.releases[&("acme".into(), "widgets".into())].tag_name, + "v1.2.3" + ); + assert_eq!( + state.manifest_conversions["manifest-code"].client_secret, + "manifest-secret" + ); + assert_eq!(state.comments.len(), 1); + assert_eq!( + state.webhook_config.url.as_deref(), + Some("https://example.com/webhooks/github") + ); + assert_eq!(state.webhook_config.content_type.as_deref(), Some("json")); + assert_eq!(state.next_installation_id, 99); + assert_eq!(state.next_pr_number, 77); + assert_eq!(state.viewer_id, "U_seeded"); + } + + #[test] + fn fixture_state_derives_public_keys_for_registered_apps() { + let fixture = FixtureState::single_app_fixture_for_test(); + let state = fixture.into_app_state().unwrap(); + assert!(state.apps["100"].public_key_pem.contains("PUBLIC KEY")); + } +} + +#[cfg(test)] +impl FixtureState { + fn single_app_fixture_for_test() -> Self { + Self { + apps: vec![FixtureApp { + app_id: "100".to_string(), + slug: "fixture-app".to_string(), + owner_login: "acme".to_string(), + public: true, + private_key_pem: test_rsa_key(), + webhook_secret: Some("whsec".to_string()), + }], + ..Self::default() + } + } +} diff --git a/test/twin/github/src/handlers/app.rs b/test/twin/github/src/handlers/app.rs new file mode 100644 index 000000000..ce165b03b --- /dev/null +++ b/test/twin/github/src/handlers/app.rs @@ -0,0 +1,260 @@ +use axum::Json; +use axum::extract::{Path, State}; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::IntoResponse; + +use crate::auth::{extract_bearer_token, verify_app_jwt}; +use crate::server::SharedState; + +/// GET /app — returns authenticated app info +pub async fn get_app(State(state): State, headers: HeaderMap) -> impl IntoResponse { + let token = match extract_bearer_token(&headers) { + Some(t) => t, + None => { + return ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({"message": "Unauthorized"})), + ) + .into_response(); + } + }; + + let state = state.read().await; + + // Try verifying the JWT against each registered app's public key + for app in state.apps.values() { + if let Ok(app_id) = verify_app_jwt(&token, &app.public_key_pem) { + if app_id == app.config.app_id { + return ( + StatusCode::OK, + Json(serde_json::json!({ + "slug": app.config.slug, + "owner": { "login": app.config.owner_login }, + })), + ) + .into_response(); + } + } + } + + ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({"message": "Unauthorized"})), + ) + .into_response() +} + +/// GET /apps/{slug} — check if app is public +pub async fn get_app_by_slug( + State(state): State, + Path(slug): Path, +) -> impl IntoResponse { + let state = state.read().await; + + for app in state.apps.values() { + if app.config.slug == slug && app.config.public { + return ( + StatusCode::OK, + Json(serde_json::json!({ + "slug": app.config.slug, + "owner": { "login": app.config.owner_login }, + })), + ) + .into_response(); + } + } + + ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"message": "Not Found"})), + ) + .into_response() +} + +/// PATCH /app/hook/config — update webhook configuration +pub async fn patch_webhook_config( + State(state): State, + headers: HeaderMap, + Json(body): Json, +) -> impl IntoResponse { + let token = match extract_bearer_token(&headers) { + Some(t) => t, + None => return StatusCode::UNAUTHORIZED, + }; + + let mut state = state.write().await; + + // Verify JWT + let mut authenticated = false; + for app in state.apps.values() { + if let Ok(app_id) = verify_app_jwt(&token, &app.public_key_pem) { + if app_id == app.config.app_id { + authenticated = true; + break; + } + } + } + + if !authenticated { + return StatusCode::UNAUTHORIZED; + } + + if let Some(url) = body.get("url").and_then(|v| v.as_str()) { + state.webhook_config.url = Some(url.to_string()); + } + if let Some(ct) = body.get("content_type").and_then(|v| v.as_str()) { + state.webhook_config.content_type = Some(ct.to_string()); + } + + StatusCode::OK +} + +#[cfg(test)] +mod tests { + use crate::server::TestServer; + use crate::state::{AppConfig, AppState}; + + fn test_rsa_key() -> String { + use std::process::Command; + let output = Command::new("openssl") + .args([ + "genpkey", + "-algorithm", + "RSA", + "-pkeyopt", + "rsa_keygen_bits:2048", + ]) + .output() + .expect("openssl should be available"); + assert!(output.status.success()); + String::from_utf8(output.stdout).unwrap() + } + + fn sign_test_jwt(app_id: &str, private_key_pem: &str) -> String { + use jsonwebtoken::{Algorithm, EncodingKey, Header, encode}; + use serde::Serialize; + + #[derive(Serialize)] + struct Claims { + iss: String, + iat: i64, + exp: i64, + } + + let now = chrono::Utc::now().timestamp(); + let claims = Claims { + iss: app_id.to_string(), + iat: now - 60, + exp: now + 600, + }; + let key = EncodingKey::from_rsa_pem(private_key_pem.as_bytes()).unwrap(); + encode(&Header::new(Algorithm::RS256), &claims, &key).unwrap() + } + + #[tokio::test] + async fn get_app_returns_app_info() { + let pem = test_rsa_key(); + let mut state = AppState::new(); + state.register_app(AppConfig { + app_id: "12345".to_string(), + slug: "my-app".to_string(), + owner_login: "my-org".to_string(), + public: true, + private_key_pem: pem.clone(), + webhook_secret: None, + }); + let server = TestServer::start(state).await; + + let jwt = sign_test_jwt("12345", &pem); + let client = reqwest::Client::new(); + let resp = client + .get(&format!("{}/app", server.url())) + .header("Authorization", format!("Bearer {jwt}")) + .header("Accept", "application/vnd.github+json") + .send() + .await + .unwrap(); + + assert_eq!(resp.status(), 200); + let body: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(body["slug"], "my-app"); + assert_eq!(body["owner"]["login"], "my-org"); + + server.shutdown().await; + } + + #[tokio::test] + async fn get_app_rejects_invalid_jwt() { + let pem = test_rsa_key(); + let mut state = AppState::new(); + state.register_app(AppConfig { + app_id: "12345".to_string(), + slug: "my-app".to_string(), + owner_login: "my-org".to_string(), + public: true, + private_key_pem: pem, + webhook_secret: None, + }); + let server = TestServer::start(state).await; + + let client = reqwest::Client::new(); + let resp = client + .get(&format!("{}/app", server.url())) + .header("Authorization", "Bearer invalid-jwt") + .send() + .await + .unwrap(); + + assert_eq!(resp.status(), 401); + server.shutdown().await; + } + + #[tokio::test] + async fn get_apps_slug_public() { + let pem = test_rsa_key(); + let mut state = AppState::new(); + state.register_app(AppConfig { + app_id: "12345".to_string(), + slug: "my-app".to_string(), + owner_login: "my-org".to_string(), + public: true, + private_key_pem: pem, + webhook_secret: None, + }); + let server = TestServer::start(state).await; + + let resp = reqwest::get(&format!("{}/apps/my-app", server.url())) + .await + .unwrap(); + assert_eq!(resp.status(), 200); + + let resp404 = reqwest::get(&format!("{}/apps/nonexistent", server.url())) + .await + .unwrap(); + assert_eq!(resp404.status(), 404); + + server.shutdown().await; + } + + #[tokio::test] + async fn get_apps_slug_private_returns_404() { + let pem = test_rsa_key(); + let mut state = AppState::new(); + state.register_app(AppConfig { + app_id: "12345".to_string(), + slug: "private-app".to_string(), + owner_login: "my-org".to_string(), + public: false, + private_key_pem: pem, + webhook_secret: None, + }); + let server = TestServer::start(state).await; + + let resp = reqwest::get(&format!("{}/apps/private-app", server.url())) + .await + .unwrap(); + assert_eq!(resp.status(), 404); + + server.shutdown().await; + } +} diff --git a/test/twin/github/src/handlers/branches.rs b/test/twin/github/src/handlers/branches.rs new file mode 100644 index 000000000..9cc7f3e26 --- /dev/null +++ b/test/twin/github/src/handlers/branches.rs @@ -0,0 +1,244 @@ +use axum::Json; +use axum::extract::{Path, State}; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::IntoResponse; + +use crate::auth::{ + BearerTokenError, InstallationTokenAccessError, authorize_installation_token, + ensure_repo_permission, +}; +use crate::server::SharedState; +use crate::state::{PermissionLevel, TokenPermission}; + +/// GET /repos/{owner}/{repo}/branches/{branch} +pub async fn get_branch( + State(state): State, + Path((owner, repo, branch)): Path<(String, String, String)>, + headers: HeaderMap, +) -> impl IntoResponse { + let state = state.read().await; + let token = match authorize_installation_token(&headers, &state) { + Ok(token) => token, + Err(BearerTokenError::Missing) => { + return ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({"message": "Unauthorized"})), + ) + .into_response(); + } + Err(BearerTokenError::Invalid) => { + return ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({"message": "Bad credentials"})), + ) + .into_response(); + } + }; + + if let Err(error) = ensure_repo_permission( + &token, + &repo, + TokenPermission::Contents, + PermissionLevel::Read, + ) { + return match error { + InstallationTokenAccessError::RepoNotAccessible => ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"message": "Not Found"})), + ) + .into_response(), + InstallationTokenAccessError::PermissionDenied => ( + StatusCode::FORBIDDEN, + Json(serde_json::json!({"message": "Resource not accessible by integration"})), + ) + .into_response(), + }; + } + + // Find repository and check branch + for repo_data in &state.repositories { + if repo_data.owner == owner && repo_data.name == repo { + if repo_data.branches.contains(&branch) { + return ( + StatusCode::OK, + Json(serde_json::json!({ + "name": branch, + "commit": { + "sha": "abc123def456", + }, + "protected": false, + })), + ) + .into_response(); + } else { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ + "message": "Branch not found" + })), + ) + .into_response(); + } + } + } + + ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"message": "Not Found"})), + ) + .into_response() +} + +#[cfg(test)] +mod tests { + use crate::server::TestServer; + use crate::state::{AppConfig, AppState}; + + fn test_rsa_key() -> String { + use std::process::Command; + let output = Command::new("openssl") + .args([ + "genpkey", + "-algorithm", + "RSA", + "-pkeyopt", + "rsa_keygen_bits:2048", + ]) + .output() + .expect("openssl should be available"); + assert!(output.status.success()); + String::from_utf8(output.stdout).unwrap() + } + + fn sign_test_jwt(app_id: &str, private_key_pem: &str) -> String { + use jsonwebtoken::{Algorithm, EncodingKey, Header, encode}; + use serde::Serialize; + + #[derive(Serialize)] + struct Claims { + iss: String, + iat: i64, + exp: i64, + } + + let now = chrono::Utc::now().timestamp(); + let claims = Claims { + iss: app_id.to_string(), + iat: now - 60, + exp: now + 600, + }; + let key = EncodingKey::from_rsa_pem(private_key_pem.as_bytes()).unwrap(); + encode(&Header::new(Algorithm::RS256), &claims, &key).unwrap() + } + + async fn get_installation_token( + client: &reqwest::Client, + jwt: &str, + owner: &str, + repo: &str, + base_url: &str, + ) -> String { + let resp = client + .get(&format!("{base_url}/repos/{owner}/{repo}/installation")) + .header("Authorization", format!("Bearer {jwt}")) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + let body: serde_json::Value = resp.json().await.unwrap(); + let install_id = body["id"].as_u64().unwrap(); + + let resp = client + .post(&format!( + "{base_url}/app/installations/{install_id}/access_tokens" + )) + .header("Authorization", format!("Bearer {jwt}")) + .json(&serde_json::json!({ + "repositories": [repo], + "permissions": {"contents": "write"} + })) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 201); + let body: serde_json::Value = resp.json().await.unwrap(); + body["token"].as_str().unwrap().to_string() + } + + #[tokio::test] + async fn branch_exists_returns_200() { + let pem = test_rsa_key(); + let mut state = AppState::new(); + state.register_app(AppConfig { + app_id: "100".to_string(), + slug: "test-app".to_string(), + owner_login: "owner".to_string(), + public: true, + private_key_pem: pem.clone(), + webhook_secret: None, + }); + state.add_installation("100", "owner", vec!["repo".to_string()], false); + state.add_repository( + "owner", + "repo", + vec!["main".to_string(), "feature".to_string()], + false, + ); + let server = TestServer::start(state).await; + + let jwt = sign_test_jwt("100", &pem); + let client = reqwest::Client::new(); + let token = get_installation_token(&client, &jwt, "owner", "repo", server.url()).await; + + let resp = client + .get(&format!( + "{}/repos/owner/repo/branches/feature", + server.url() + )) + .header("Authorization", format!("Bearer {token}")) + .header("Accept", "application/vnd.github+json") + .send() + .await + .unwrap(); + + assert_eq!(resp.status(), 200); + let body: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(body["name"], "feature"); + + server.shutdown().await; + } + + #[tokio::test] + async fn branch_not_found_returns_404() { + let pem = test_rsa_key(); + let mut state = AppState::new(); + state.register_app(AppConfig { + app_id: "100".to_string(), + slug: "test-app".to_string(), + owner_login: "owner".to_string(), + public: true, + private_key_pem: pem.clone(), + webhook_secret: None, + }); + state.add_installation("100", "owner", vec!["repo".to_string()], false); + state.add_repository("owner", "repo", vec!["main".to_string()], false); + let server = TestServer::start(state).await; + + let jwt = sign_test_jwt("100", &pem); + let client = reqwest::Client::new(); + let token = get_installation_token(&client, &jwt, "owner", "repo", server.url()).await; + + let resp = client + .get(&format!( + "{}/repos/owner/repo/branches/nonexistent", + server.url() + )) + .header("Authorization", format!("Bearer {token}")) + .send() + .await + .unwrap(); + + assert_eq!(resp.status(), 404); + server.shutdown().await; + } +} diff --git a/test/twin/github/src/handlers/git.rs b/test/twin/github/src/handlers/git.rs new file mode 100644 index 000000000..eb0637d4c --- /dev/null +++ b/test/twin/github/src/handlers/git.rs @@ -0,0 +1,407 @@ +use axum::body::Body; +use axum::extract::{Path, Query, State}; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::{IntoResponse, Response}; +use std::path::PathBuf; +use std::process::Stdio; +use tokio::io::AsyncWriteExt; +use tokio::process::Command; + +use crate::server::SharedState; +use crate::state::{PermissionLevel, TokenPermission}; + +/// Find the git-http-backend binary by querying `git --exec-path`. +fn find_git_http_backend() -> Result { + let output = std::process::Command::new("git") + .arg("--exec-path") + .output() + .map_err(|e| format!("failed to run git --exec-path: {e}"))?; + if !output.status.success() { + return Err("git --exec-path failed".to_string()); + } + let exec_path = String::from_utf8(output.stdout) + .map_err(|e| format!("invalid utf-8 from git --exec-path: {e}"))?; + let backend = PathBuf::from(exec_path.trim()).join("git-http-backend"); + if backend.exists() { + Ok(backend) + } else { + Err(format!( + "git-http-backend not found at {}", + backend.display() + )) + } +} + +/// Extract Basic Auth credentials from the Authorization header. +/// Returns (username, password) if present. +fn extract_basic_auth(headers: &HeaderMap) -> Option<(String, String)> { + let auth = headers.get("Authorization")?.to_str().ok()?; + let encoded = auth.strip_prefix("Basic ")?; + let decoded = String::from_utf8( + base64::Engine::decode(&base64::engine::general_purpose::STANDARD, encoded).ok()?, + ) + .ok()?; + let (user, pass) = decoded.split_once(':')?; + Some((user.to_string(), pass.to_string())) +} + +/// Determine the required auth level for a git operation. +/// Returns None if no auth is needed (public repo read). +/// Returns Some(PermissionLevel) if auth is required. +fn required_permission(repo_is_private: bool, service: &str) -> Option { + match service { + "git-receive-pack" => Some(PermissionLevel::Write), + "git-upload-pack" => { + if repo_is_private { + Some(PermissionLevel::Read) + } else { + None // Public repo read: no auth needed + } + } + _ => Some(PermissionLevel::Read), // Unknown service: require auth + } +} + +/// Shared handler logic for all git HTTP endpoints. +#[allow(clippy::too_many_arguments)] +async fn handle_git_cgi( + state: SharedState, + headers: HeaderMap, + repo_owner: &str, + repo_name: &str, + path_info: &str, + query_string: &str, + request_method: &str, + content_type: Option<&str>, + body_bytes: Vec, +) -> Response { + // Determine the service from query string or path + let service = if let Some(svc) = query_string + .split('&') + .find_map(|param| param.strip_prefix("service=")) + { + svc.to_string() + } else if path_info.contains("git-upload-pack") { + "git-upload-pack".to_string() + } else if path_info.contains("git-receive-pack") { + "git-receive-pack".to_string() + } else { + "git-upload-pack".to_string() // default for info/refs without service param + }; + + // Look up repo and check auth + let git_dir = { + let state = state.read().await; + let repo = state + .repositories + .iter() + .find(|r| r.owner == repo_owner && r.name == repo_name); + + let repo = match repo { + Some(r) => r, + None => { + return (StatusCode::NOT_FOUND, "Repository not found").into_response(); + } + }; + + let git_dir = match &repo.git_dir { + Some(d) => d.clone(), + None => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + "Git repository not initialized", + ) + .into_response(); + } + }; + + // Auth check + if let Some(required_level) = required_permission(repo.private, &service) { + let creds = extract_basic_auth(&headers); + match creds { + None => { + return Response::builder() + .status(StatusCode::UNAUTHORIZED) + .header("WWW-Authenticate", "Basic realm=\"twin-github\"") + .body(Body::from("Authentication required")) + .unwrap(); + } + Some((_username, password)) => { + let token_info = state.validate_token(&password); + match token_info { + None => { + return (StatusCode::FORBIDDEN, "Bad credentials").into_response(); + } + Some(token_info) => { + if !token_info.allows_repo(repo_name) { + return (StatusCode::NOT_FOUND, "Repository not found") + .into_response(); + } + if !token_info.allows(TokenPermission::Contents, required_level) { + return (StatusCode::FORBIDDEN, "Insufficient permissions") + .into_response(); + } + } + } + } + } + } + + git_dir + }; + + // Find git-http-backend + let backend = match find_git_http_backend() { + Ok(b) => b, + Err(e) => { + tracing::error!("Cannot find git-http-backend: {e}"); + return (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(); + } + }; + + // git_dir is e.g. /tmp/xxx/owner/repo.git + // GIT_PROJECT_ROOT should be /tmp/xxx + let git_project_root = git_dir + .parent() // /tmp/xxx/owner + .and_then(|p| p.parent()) // /tmp/xxx + .expect("git_dir should have grandparent"); + + // The PATH_INFO for git-http-backend should be: /{owner}/{repo}.git/{sub-path} + let cgi_path_info = format!("/{repo_owner}/{repo_name}.git{path_info}"); + + // Spawn git-http-backend as CGI + let mut cmd = Command::new(&backend); + cmd.env("GIT_PROJECT_ROOT", git_project_root) + .env("GIT_HTTP_EXPORT_ALL", "1") + .env("PATH_INFO", &cgi_path_info) + .env("REQUEST_METHOD", request_method) + .env("QUERY_STRING", query_string) + .env("CONTENT_TYPE", content_type.unwrap_or("")) + .env("CONTENT_LENGTH", body_bytes.len().to_string()) + .env("SERVER_PROTOCOL", "HTTP/1.1") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let mut child = match cmd.spawn() { + Ok(c) => c, + Err(e) => { + tracing::error!("Failed to spawn git-http-backend: {e}"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to spawn git-http-backend: {e}"), + ) + .into_response(); + } + }; + + // Write request body to stdin + if let Some(mut stdin) = child.stdin.take() { + if !body_bytes.is_empty() { + let _ = stdin.write_all(&body_bytes).await; + } + drop(stdin); // Close stdin to signal EOF + } + + // Read stdout + let output = match child.wait_with_output().await { + Ok(o) => o, + Err(e) => { + tracing::error!("git-http-backend failed: {e}"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("git-http-backend failed: {e}"), + ) + .into_response(); + } + }; + + if !output.status.success() && output.stdout.is_empty() { + let stderr = String::from_utf8_lossy(&output.stderr); + tracing::error!("git-http-backend exited with error: {stderr}"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("git-http-backend error: {stderr}"), + ) + .into_response(); + } + + // Parse CGI response: headers then body, separated by \r\n\r\n or \n\n + parse_cgi_response(&output.stdout) +} + +/// Parse a CGI response (headers + body) into an Axum Response. +fn parse_cgi_response(raw: &[u8]) -> Response { + // Find the header/body separator + let (header_end, body_start) = if let Some(pos) = find_subsequence(raw, b"\r\n\r\n") { + (pos, pos + 4) + } else if let Some(pos) = find_subsequence(raw, b"\n\n") { + (pos, pos + 2) + } else { + // No separator found -- treat entire output as body + return Response::builder() + .status(StatusCode::OK) + .body(Body::from(raw.to_vec())) + .unwrap(); + }; + + let header_bytes = &raw[..header_end]; + let body_bytes = &raw[body_start..]; + + let header_str = String::from_utf8_lossy(header_bytes); + let mut status = StatusCode::OK; + let mut builder = Response::builder(); + + for line in header_str.lines() { + if let Some(status_str) = line.strip_prefix("Status: ") { + if let Some(code_str) = status_str.split_whitespace().next() { + if let Ok(code) = code_str.parse::() { + if let Ok(s) = StatusCode::from_u16(code) { + status = s; + } + } + } + } else if let Some((name, value)) = line.split_once(": ") { + builder = builder.header(name, value.trim()); + } + } + + builder + .status(status) + .body(Body::from(body_bytes.to_vec())) + .unwrap() +} + +fn find_subsequence(haystack: &[u8], needle: &[u8]) -> Option { + haystack + .windows(needle.len()) + .position(|window| window == needle) +} + +// ---- Axum route handlers ---- + +#[derive(serde::Deserialize)] +pub struct ServiceQuery { + service: Option, +} + +/// Handler for GET /{owner}/{repo}/info/refs (works with and without .git suffix) +pub async fn git_info_refs( + State(state): State, + Path((owner, repo_with_suffix)): Path<(String, String)>, + Query(query): Query, + headers: HeaderMap, +) -> Response { + let repo = repo_with_suffix + .strip_suffix(".git") + .unwrap_or(&repo_with_suffix); + let query_string = match &query.service { + Some(svc) => format!("service={svc}"), + None => String::new(), + }; + + handle_git_cgi( + state, + headers, + &owner, + repo, + "/info/refs", + &query_string, + "GET", + None, + Vec::new(), + ) + .await +} + +/// Handler for POST /{owner}/{repo}/git-upload-pack (works with and without .git suffix) +pub async fn git_upload_pack( + State(state): State, + Path((owner, repo_with_suffix)): Path<(String, String)>, + headers: HeaderMap, + body: axum::body::Bytes, +) -> Response { + let repo = repo_with_suffix + .strip_suffix(".git") + .unwrap_or(&repo_with_suffix); + let content_type = headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + + handle_git_cgi( + state, + headers, + &owner, + repo, + "/git-upload-pack", + "", + "POST", + content_type.as_deref(), + body.to_vec(), + ) + .await +} + +/// Handler for POST /{owner}/{repo}/git-receive-pack (works with and without .git suffix) +pub async fn git_receive_pack( + State(state): State, + Path((owner, repo_with_suffix)): Path<(String, String)>, + headers: HeaderMap, + body: axum::body::Bytes, +) -> Response { + let repo = repo_with_suffix + .strip_suffix(".git") + .unwrap_or(&repo_with_suffix); + let content_type = headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + + handle_git_cgi( + state, + headers, + &owner, + repo, + "/git-receive-pack", + "", + "POST", + content_type.as_deref(), + body.to_vec(), + ) + .await +} + +#[cfg(test)] +mod tests { + use crate::server::TestServer; + use crate::state::AppState; + + #[tokio::test] + async fn info_refs_returns_valid_git_response_for_public_repo() { + let mut state = AppState::new(); + state.add_repository("owner", "repo", vec!["main".to_string()], false); + let server = TestServer::start(state).await; + + let client = reqwest::Client::new(); + let resp = client + .get(format!( + "{}/owner/repo.git/info/refs?service=git-upload-pack", + server.url() + )) + .send() + .await + .unwrap(); + + assert_eq!(resp.status(), 200); + let content_type = resp + .headers() + .get("content-type") + .unwrap() + .to_str() + .unwrap(); + assert_eq!(content_type, "application/x-git-upload-pack-advertisement"); + + server.shutdown().await; + } +} diff --git a/test/twin/github/src/handlers/graphql.rs b/test/twin/github/src/handlers/graphql.rs new file mode 100644 index 000000000..1ae8d4a08 --- /dev/null +++ b/test/twin/github/src/handlers/graphql.rs @@ -0,0 +1,1053 @@ +use axum::Json; +use axum::extract::State; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::IntoResponse; + +use crate::auth::{ + BearerTokenError, GraphqlActor, authorize_graphql_actor, ensure_permission, + ensure_repo_permission, +}; +use crate::server::SharedState; +use crate::state::{AutoMerge, Comment, OwnerType, PermissionLevel, TokenPermission}; + +fn graphql_auth_error_response(error: BearerTokenError) -> axum::response::Response { + match error { + BearerTokenError::Missing => ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({"message": "Unauthorized"})), + ) + .into_response(), + BearerTokenError::Invalid => ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({"message": "Bad credentials"})), + ) + .into_response(), + } +} + +fn graphql_permission_error_response() -> axum::response::Response { + ( + StatusCode::OK, + Json(serde_json::json!({ + "errors": [{"message": "Resource not accessible by integration"}] + })), + ) + .into_response() +} + +fn graphql_project_lookup_inaccessible_response(number: u64) -> axum::response::Response { + ( + StatusCode::OK, + Json(serde_json::json!({ + "data": { + "organization": { + "projectV2": null + } + }, + "errors": [{ + "message": format!("Could not resolve to a ProjectV2 with the number {}.", number) + }] + })), + ) + .into_response() +} + +/// POST /graphql — handles all GraphQL operations via pattern matching +pub async fn handle_graphql( + State(state): State, + headers: HeaderMap, + Json(body): Json, +) -> impl IntoResponse { + let actor = { + let state = state.read().await; + match authorize_graphql_actor(&headers, &state) { + Ok(actor) => actor, + Err(error) => return graphql_auth_error_response(error), + } + }; + + let query = body.get("query").and_then(|v| v.as_str()).unwrap_or(""); + let variables = body + .get("variables") + .cloned() + .unwrap_or(serde_json::json!({})); + + // Pattern match on query content + if query.contains("viewer") && query.contains("id") && !query.contains("mutation") { + return handle_viewer_query(state).await; + } + + if query.contains("enablePullRequestAutoMerge") { + return handle_enable_auto_merge(state, &actor, query).await; + } + + if query.contains("addComment") { + return handle_add_comment(state, &actor, &variables).await; + } + + if query.contains("updateProjectV2ItemFieldValue") { + return handle_update_project_item(state, &actor, &variables).await; + } + + if query.contains("organization") && query.contains("projectV2") { + return handle_org_project_query(state, &actor, &variables).await; + } + + if query.contains("user") && query.contains("projectV2") && !query.contains("organization") { + return handle_user_project_query(state, &actor, &variables).await; + } + + if query.contains("node") && query.contains("items") { + return handle_project_items_query(state, &actor, &variables).await; + } + + if query.contains("field") && query.contains("Status") { + return handle_project_field_query(state, &actor, &variables).await; + } + + ( + StatusCode::OK, + Json(serde_json::json!({ + "errors": [{"message": "Unsupported query"}] + })), + ) + .into_response() +} + +async fn handle_viewer_query(state: SharedState) -> axum::response::Response { + let state = state.read().await; + ( + StatusCode::OK, + Json(serde_json::json!({ + "data": { + "viewer": { + "id": state.viewer_id + } + } + })), + ) + .into_response() +} + +async fn handle_enable_auto_merge( + state: SharedState, + actor: &GraphqlActor, + query: &str, +) -> axum::response::Response { + // Extract pullRequestId from the query string (inline format) + let pr_id = extract_quoted_value(query, "pullRequestId:"); + let merge_method = extract_unquoted_value(query, "mergeMethod:"); + + if let Some(pr_id) = pr_id { + let mut state = state.write().await; + let now = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); + let method = merge_method.unwrap_or_else(|| "SQUASH".to_string()); + + // Find the PR by node_id and update auto_merge + for ((_, repo), prs) in &mut state.pull_requests { + for pr in prs.iter_mut() { + if pr.node_id == pr_id { + if let GraphqlActor::InstallationToken(token) = actor { + if ensure_repo_permission( + token, + repo, + TokenPermission::PullRequests, + PermissionLevel::Write, + ) + .is_err() + { + return graphql_permission_error_response(); + } + } + pr.auto_merge = Some(AutoMerge { + enabled_at: now.clone(), + merge_method: method.clone(), + }); + return ( + StatusCode::OK, + Json(serde_json::json!({ + "data": { + "enablePullRequestAutoMerge": { + "pullRequest": { + "autoMergeRequest": { + "enabledAt": now, + "mergeMethod": method, + } + } + } + } + })), + ) + .into_response(); + } + } + } + } + + ( + StatusCode::OK, + Json(serde_json::json!({ + "errors": [{"message": "Pull request not found"}] + })), + ) + .into_response() +} + +async fn handle_add_comment( + state: SharedState, + actor: &GraphqlActor, + variables: &serde_json::Value, +) -> axum::response::Response { + let subject_id = variables + .get("subjectId") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let body = variables + .get("body") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + let mut state = state.write().await; + if let GraphqlActor::InstallationToken(token) = actor { + for ((_, repo), prs) in &state.pull_requests { + if prs.iter().any(|pr| pr.node_id == subject_id) && !token.allows_repo(repo) { + return graphql_permission_error_response(); + } + } + } + state.comments.push(Comment { + issue_node_id: subject_id, + body, + }); + + ( + StatusCode::OK, + Json(serde_json::json!({ + "data": { + "addComment": { + "clientMutationId": null + } + } + })), + ) + .into_response() +} + +async fn handle_org_project_query( + state: SharedState, + actor: &GraphqlActor, + variables: &serde_json::Value, +) -> axum::response::Response { + let owner = variables + .get("owner") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let number = variables + .get("number") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + + let state = state.read().await; + if let GraphqlActor::InstallationToken(token) = actor { + if ensure_permission( + token, + TokenPermission::OrganizationProjects, + PermissionLevel::Read, + ) + .is_err() + { + return graphql_project_lookup_inaccessible_response(number); + } + } + for project in &state.projects { + if project.owner == owner + && project.number == number + && project.owner_type == OwnerType::Organization + { + return ( + StatusCode::OK, + Json(serde_json::json!({ + "data": { + "organization": { + "projectV2": { + "id": project.node_id + } + } + } + })), + ) + .into_response(); + } + } + + // Return null projectV2 (not an error — the caller handles the fallback to user query) + ( + StatusCode::OK, + Json(serde_json::json!({ + "data": { + "organization": { + "projectV2": null + } + } + })), + ) + .into_response() +} + +async fn handle_user_project_query( + state: SharedState, + _actor: &GraphqlActor, + variables: &serde_json::Value, +) -> axum::response::Response { + let owner = variables + .get("owner") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let number = variables + .get("number") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + + let state = state.read().await; + for project in &state.projects { + if project.owner == owner + && project.number == number + && project.owner_type == OwnerType::User + { + return ( + StatusCode::OK, + Json(serde_json::json!({ + "data": { + "user": { + "projectV2": { + "id": project.node_id + } + } + } + })), + ) + .into_response(); + } + } + + ( + StatusCode::OK, + Json(serde_json::json!({ + "data": { + "user": { + "projectV2": null + } + } + })), + ) + .into_response() +} + +async fn handle_project_items_query( + state: SharedState, + actor: &GraphqlActor, + variables: &serde_json::Value, +) -> axum::response::Response { + let project_id = variables + .get("projectId") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + let state = state.read().await; + for project in &state.projects { + if project.node_id == project_id { + if project.owner_type == OwnerType::Organization { + if let GraphqlActor::InstallationToken(token) = actor { + if ensure_permission( + token, + TokenPermission::OrganizationProjects, + PermissionLevel::Read, + ) + .is_err() + { + return graphql_permission_error_response(); + } + } + } + let nodes: Vec = project + .items + .iter() + .map(|item| { + let assignee_nodes: Vec = item + .content + .assignee_ids + .iter() + .map(|id| serde_json::json!({"id": id})) + .collect(); + let label_nodes: Vec = item + .content + .labels + .iter() + .map(|name| serde_json::json!({"name": name})) + .collect(); + + serde_json::json!({ + "id": item.id, + "fieldValueByName": { + "name": item.status + }, + "content": { + "id": item.content.id, + "number": item.content.number, + "title": item.content.title, + "body": item.content.body, + "url": item.content.url, + "createdAt": item.content.created_at, + "updatedAt": item.content.updated_at, + "assignees": { + "nodes": assignee_nodes + }, + "labels": { + "nodes": label_nodes + } + } + }) + }) + .collect(); + + return ( + StatusCode::OK, + Json(serde_json::json!({ + "data": { + "node": { + "items": { + "nodes": nodes, + "pageInfo": { + "hasNextPage": false, + "endCursor": null + } + } + } + } + })), + ) + .into_response(); + } + } + + ( + StatusCode::OK, + Json(serde_json::json!({ + "data": { + "node": { + "items": { + "nodes": [], + "pageInfo": { + "hasNextPage": false, + "endCursor": null + } + } + } + } + })), + ) + .into_response() +} + +async fn handle_project_field_query( + state: SharedState, + actor: &GraphqlActor, + variables: &serde_json::Value, +) -> axum::response::Response { + let project_id = variables + .get("projectId") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + let state = state.read().await; + for project in &state.projects { + if project.node_id == project_id { + if project.owner_type == OwnerType::Organization { + if let GraphqlActor::InstallationToken(token) = actor { + if ensure_permission( + token, + TokenPermission::OrganizationProjects, + PermissionLevel::Read, + ) + .is_err() + { + return graphql_permission_error_response(); + } + } + } + let options: Vec = project + .status_options + .iter() + .map(|opt| serde_json::json!({"id": opt.id, "name": opt.name})) + .collect(); + + return ( + StatusCode::OK, + Json(serde_json::json!({ + "data": { + "node": { + "field": { + "id": project.status_field_id, + "options": options + } + } + } + })), + ) + .into_response(); + } + } + + ( + StatusCode::OK, + Json(serde_json::json!({ + "errors": [{"message": "Project not found"}] + })), + ) + .into_response() +} + +async fn handle_update_project_item( + state: SharedState, + actor: &GraphqlActor, + variables: &serde_json::Value, +) -> axum::response::Response { + let project_id = variables + .get("projectId") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let item_id = variables + .get("itemId") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let option_id = variables + .get("optionId") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + let mut state = state.write().await; + for project in &mut state.projects { + if project.node_id == project_id { + if project.owner_type == OwnerType::Organization { + if let GraphqlActor::InstallationToken(token) = actor { + if ensure_permission( + token, + TokenPermission::OrganizationProjects, + PermissionLevel::Write, + ) + .is_err() + { + return graphql_permission_error_response(); + } + } + } + // Find the option name by ID + let option_name = project + .status_options + .iter() + .find(|o| o.id == option_id) + .map(|o| o.name.clone()); + + if let Some(name) = option_name { + for item in &mut project.items { + if item.id == item_id { + item.status = name; + return ( + StatusCode::OK, + Json(serde_json::json!({ + "data": { + "updateProjectV2ItemFieldValue": { + "projectV2Item": { + "id": item_id + } + } + } + })), + ) + .into_response(); + } + } + } + } + } + + ( + StatusCode::OK, + Json(serde_json::json!({ + "errors": [{"message": "Item not found"}] + })), + ) + .into_response() +} + +/// Extract a quoted value after a key in a string, e.g. `pullRequestId: "PR_123"` -> `PR_123` +fn extract_quoted_value(s: &str, key: &str) -> Option { + let idx = s.find(key)?; + let rest = &s[idx + key.len()..]; + let rest = rest.trim_start(); + if let Some(rest) = rest.strip_prefix('"') { + let end = rest.find('"')?; + Some(rest[..end].to_string()) + } else { + None + } +} + +/// Extract an unquoted value after a key, e.g. `mergeMethod: SQUASH` -> `SQUASH` +fn extract_unquoted_value(s: &str, key: &str) -> Option { + let idx = s.find(key)?; + let rest = &s[idx + key.len()..]; + let rest = rest.trim_start(); + let end = rest + .find(|c: char| !c.is_alphanumeric() && c != '_') + .unwrap_or(rest.len()); + if end > 0 { + Some(rest[..end].to_string()) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use crate::server::TestServer; + use crate::state::{AppConfig, AppState, PullRequest}; + + fn test_rsa_key() -> String { + use std::process::Command; + let output = Command::new("openssl") + .args([ + "genpkey", + "-algorithm", + "RSA", + "-pkeyopt", + "rsa_keygen_bits:2048", + ]) + .output() + .expect("openssl should be available"); + assert!(output.status.success()); + String::from_utf8(output.stdout).unwrap() + } + + fn sign_test_jwt(app_id: &str, private_key_pem: &str) -> String { + use jsonwebtoken::{Algorithm, EncodingKey, Header, encode}; + use serde::Serialize; + + #[derive(Serialize)] + struct Claims { + iss: String, + iat: i64, + exp: i64, + } + + let now = chrono::Utc::now().timestamp(); + let claims = Claims { + iss: app_id.to_string(), + iat: now - 60, + exp: now + 600, + }; + let key = EncodingKey::from_rsa_pem(private_key_pem.as_bytes()).unwrap(); + encode(&Header::new(Algorithm::RS256), &claims, &key).unwrap() + } + + async fn get_installation_token( + client: &reqwest::Client, + jwt: &str, + owner: &str, + repo: &str, + base_url: &str, + ) -> String { + // Step 1: GET /repos/{owner}/{repo}/installation to get installation ID + let resp = client + .get(&format!("{base_url}/repos/{owner}/{repo}/installation")) + .header("Authorization", format!("Bearer {jwt}")) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + let body: serde_json::Value = resp.json().await.unwrap(); + let install_id = body["id"].as_u64().unwrap(); + + // Step 2: POST /app/installations/{id}/access_tokens + let resp = client + .post(&format!( + "{base_url}/app/installations/{install_id}/access_tokens" + )) + .header("Authorization", format!("Bearer {jwt}")) + .json(&serde_json::json!({ + "repositories": [repo], + "permissions": { + "contents": "write", + "pull_requests": "write", + "issues": "write", + "organization_projects": "write" + } + })) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 201); + let body: serde_json::Value = resp.json().await.unwrap(); + body["token"].as_str().unwrap().to_string() + } + + async fn setup_with_token( + state: &mut AppState, + pem: &str, + ) -> (TestServer, reqwest::Client, String) { + state.register_app(AppConfig { + app_id: "100".to_string(), + slug: "test-app".to_string(), + owner_login: "owner".to_string(), + public: true, + private_key_pem: pem.to_string(), + webhook_secret: None, + }); + state.add_installation("100", "owner", vec!["repo".to_string()], false); + state.add_repository("owner", "repo", vec!["main".to_string()], false); + let server = TestServer::start(state.clone()).await; + + let jwt = sign_test_jwt("100", pem); + let client = reqwest::Client::new(); + let token = get_installation_token(&client, &jwt, "owner", "repo", server.url()).await; + + (server, client, token) + } + + #[tokio::test] + async fn viewer_query_returns_id() { + let pem = test_rsa_key(); + let mut state = AppState::new(); + state.viewer_id = "U_testviewer".to_string(); + let (server, client, token) = setup_with_token(&mut state, &pem).await; + + let resp = client + .post(&format!("{}/graphql", server.url())) + .header("Authorization", format!("Bearer {token}")) + .json(&serde_json::json!({ + "query": "query { viewer { id } }", + "variables": {} + })) + .send() + .await + .unwrap(); + + assert_eq!(resp.status(), 200); + let body: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(body["data"]["viewer"]["id"], "U_testviewer"); + server.shutdown().await; + } + + #[tokio::test] + async fn enable_auto_merge_mutation() { + let pem = test_rsa_key(); + let mut state = AppState::new(); + // Pre-seed a PR + state + .pull_requests + .entry(("owner".to_string(), "repo".to_string())) + .or_default() + .push(PullRequest { + number: 1, + node_id: "PR_test123".to_string(), + title: "Test".to_string(), + body: "".to_string(), + state: "open".to_string(), + draft: false, + mergeable: true, + additions: 10, + deletions: 5, + changed_files: 2, + html_url: "https://github.com/owner/repo/pull/1".to_string(), + user_login: "test-bot[bot]".to_string(), + head_ref: "feature".to_string(), + base_ref: "main".to_string(), + created_at: "2026-01-01T00:00:00Z".to_string(), + updated_at: "2026-01-01T00:00:00Z".to_string(), + auto_merge: None, + }); + let (server, client, token) = setup_with_token(&mut state, &pem).await; + + let query = r#"mutation { + enablePullRequestAutoMerge(input: {pullRequestId: "PR_test123", mergeMethod: SQUASH}) { + pullRequest { + autoMergeRequest { + enabledAt + mergeMethod + } + } + } + }"#; + + let resp = client + .post(&format!("{}/graphql", server.url())) + .header("Authorization", format!("Bearer {token}")) + .json(&serde_json::json!({ "query": query })) + .send() + .await + .unwrap(); + + assert_eq!(resp.status(), 200); + let body: serde_json::Value = resp.json().await.unwrap(); + assert!( + body["data"]["enablePullRequestAutoMerge"]["pullRequest"]["autoMergeRequest"] + ["enabledAt"] + .is_string() + ); + assert!(body["errors"].is_null()); + + server.shutdown().await; + } + + #[tokio::test] + async fn graphql_unauthorized_returns_401() { + let state = AppState::new(); + let server = TestServer::start(state).await; + + let client = reqwest::Client::new(); + let resp = client + .post(&format!("{}/graphql", server.url())) + .header("Authorization", "Bearer invalid-token") + .json(&serde_json::json!({ + "query": "query { viewer { id } }", + "variables": {} + })) + .send() + .await + .unwrap(); + + assert_eq!(resp.status(), 401); + server.shutdown().await; + } + + #[tokio::test] + async fn org_project_query_returns_node_id() { + let pem = test_rsa_key(); + let mut state = AppState::new(); + state.projects.push(crate::state::Project { + node_id: "PVT_org123".to_string(), + number: 1, + owner: "owner".to_string(), + owner_type: crate::state::OwnerType::Organization, + status_field_id: "PVTSSF_status1".to_string(), + status_options: vec![ + crate::state::StatusOption { + id: "opt1".to_string(), + name: "Todo".to_string(), + }, + crate::state::StatusOption { + id: "opt2".to_string(), + name: "In Progress".to_string(), + }, + crate::state::StatusOption { + id: "opt3".to_string(), + name: "Done".to_string(), + }, + ], + items: vec![crate::state::ProjectItem { + id: "PVTI_item1".to_string(), + status: "Todo".to_string(), + content: crate::state::IssueContent { + id: "I_issue1".to_string(), + number: 42, + title: "Fix bug".to_string(), + body: "Description".to_string(), + url: "https://github.com/owner/repo/issues/42".to_string(), + created_at: "2026-01-01T00:00:00Z".to_string(), + updated_at: "2026-01-02T00:00:00Z".to_string(), + assignee_ids: vec![], + labels: vec!["bug".to_string()], + }, + }], + }); + let (server, client, token) = setup_with_token(&mut state, &pem).await; + + // Query org project + let query = r#" + query($owner: String!, $number: Int!) { + organization(login: $owner) { + projectV2(number: $number) { id } + } + } + "#; + let resp = client + .post(&format!("{}/graphql", server.url())) + .header("Authorization", format!("Bearer {token}")) + .json(&serde_json::json!({ + "query": query, + "variables": { "owner": "owner", "number": 1 } + })) + .send() + .await + .unwrap(); + + assert_eq!(resp.status(), 200); + let body: serde_json::Value = resp.json().await.unwrap(); + assert_eq!( + body["data"]["organization"]["projectV2"]["id"], + "PVT_org123" + ); + server.shutdown().await; + } + + #[tokio::test] + async fn project_items_query_returns_paginated() { + let pem = test_rsa_key(); + let mut state = AppState::new(); + state.projects.push(crate::state::Project { + node_id: "PVT_test".to_string(), + number: 1, + owner: "owner".to_string(), + owner_type: crate::state::OwnerType::Organization, + status_field_id: "PVTSSF_s".to_string(), + status_options: vec![], + items: vec![crate::state::ProjectItem { + id: "PVTI_1".to_string(), + status: "Todo".to_string(), + content: crate::state::IssueContent { + id: "I_1".to_string(), + number: 1, + title: "Issue 1".to_string(), + body: "Body".to_string(), + url: "https://github.com/owner/repo/issues/1".to_string(), + created_at: "2026-01-01T00:00:00Z".to_string(), + updated_at: "2026-01-01T00:00:00Z".to_string(), + assignee_ids: vec!["U_user1".to_string()], + labels: vec!["bug".to_string(), "urgent".to_string()], + }, + }], + }); + let (server, client, token) = setup_with_token(&mut state, &pem).await; + + let query = r#" + query($projectId: ID!, $cursor: String) { + node(id: $projectId) { + ... on ProjectV2 { + items(first: 100, after: $cursor) { + nodes { + id + fieldValueByName(name: "Status") { + ... on ProjectV2ItemFieldSingleSelectValue { name } + } + content { + ... on Issue { + id number title body url createdAt updatedAt + assignees(first: 1) { nodes { id } } + labels(first: 20) { nodes { name } } + } + } + } + pageInfo { hasNextPage endCursor } + } + } + } + } + "#; + let resp = client + .post(&format!("{}/graphql", server.url())) + .header("Authorization", format!("Bearer {token}")) + .json(&serde_json::json!({ + "query": query, + "variables": { "projectId": "PVT_test", "cursor": null } + })) + .send() + .await + .unwrap(); + + assert_eq!(resp.status(), 200); + let body: serde_json::Value = resp.json().await.unwrap(); + let items = &body["data"]["node"]["items"]["nodes"]; + assert_eq!(items.as_array().unwrap().len(), 1); + assert_eq!(items[0]["id"], "PVTI_1"); + assert_eq!(items[0]["fieldValueByName"]["name"], "Todo"); + assert_eq!(items[0]["content"]["number"], 1); + assert_eq!( + items[0]["content"]["assignees"]["nodes"][0]["id"], + "U_user1" + ); + assert_eq!(items[0]["content"]["labels"]["nodes"][0]["name"], "bug"); + assert_eq!( + body["data"]["node"]["items"]["pageInfo"]["hasNextPage"], + false + ); + + server.shutdown().await; + } + + #[tokio::test] + async fn update_project_item_field_value() { + let pem = test_rsa_key(); + let mut state = AppState::new(); + state.projects.push(crate::state::Project { + node_id: "PVT_test".to_string(), + number: 1, + owner: "owner".to_string(), + owner_type: crate::state::OwnerType::Organization, + status_field_id: "PVTSSF_s".to_string(), + status_options: vec![ + crate::state::StatusOption { + id: "opt1".to_string(), + name: "Todo".to_string(), + }, + crate::state::StatusOption { + id: "opt2".to_string(), + name: "Done".to_string(), + }, + ], + items: vec![crate::state::ProjectItem { + id: "PVTI_1".to_string(), + status: "Todo".to_string(), + content: crate::state::IssueContent { + id: "I_1".to_string(), + number: 1, + title: "Issue 1".to_string(), + body: "".to_string(), + url: "https://github.com/owner/repo/issues/1".to_string(), + created_at: "2026-01-01T00:00:00Z".to_string(), + updated_at: "2026-01-01T00:00:00Z".to_string(), + assignee_ids: vec![], + labels: vec![], + }, + }], + }); + let (server, client, token) = setup_with_token(&mut state, &pem).await; + + let query = r#" + mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId + itemId: $itemId + fieldId: $fieldId + value: { singleSelectOptionId: $optionId } + }) { + projectV2Item { id } + } + } + "#; + let resp = client + .post(&format!("{}/graphql", server.url())) + .header("Authorization", format!("Bearer {token}")) + .json(&serde_json::json!({ + "query": query, + "variables": { + "projectId": "PVT_test", + "itemId": "PVTI_1", + "fieldId": "PVTSSF_s", + "optionId": "opt2" + } + })) + .send() + .await + .unwrap(); + + assert_eq!(resp.status(), 200); + let body: serde_json::Value = resp.json().await.unwrap(); + assert_eq!( + body["data"]["updateProjectV2ItemFieldValue"]["projectV2Item"]["id"], + "PVTI_1" + ); + server.shutdown().await; + } +} diff --git a/test/twin/github/src/handlers/installations.rs b/test/twin/github/src/handlers/installations.rs new file mode 100644 index 000000000..5d75fe981 --- /dev/null +++ b/test/twin/github/src/handlers/installations.rs @@ -0,0 +1,379 @@ +use axum::Json; +use axum::extract::{Path, State}; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::IntoResponse; + +use crate::auth::{extract_bearer_token, verify_app_jwt}; +use crate::server::SharedState; + +/// GET /repos/{owner}/{repo}/installation +pub async fn get_installation( + State(state): State, + Path((owner, repo)): Path<(String, String)>, + headers: HeaderMap, +) -> impl IntoResponse { + let token = match extract_bearer_token(&headers) { + Some(t) => t, + None => { + return ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({"message": "Unauthorized"})), + ) + .into_response(); + } + }; + + let state = state.read().await; + + // Verify JWT against registered apps + let mut authenticated = false; + for app in state.apps.values() { + if let Ok(app_id) = verify_app_jwt(&token, &app.public_key_pem) { + if app_id == app.config.app_id { + authenticated = true; + break; + } + } + } + + if !authenticated { + return ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({"message": "Unauthorized"})), + ) + .into_response(); + } + + match state.find_installation(&owner, &repo) { + Some(installation) if installation.suspended => ( + StatusCode::FORBIDDEN, + Json(serde_json::json!({ + "message": "GitHub App installation is suspended" + })), + ) + .into_response(), + Some(installation) => ( + StatusCode::OK, + Json(serde_json::json!({ + "id": installation.id, + "app_id": installation.app_id, + "account": { + "login": installation.owner, + }, + })), + ) + .into_response(), + None => ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ + "message": "Not Found" + })), + ) + .into_response(), + } +} + +/// POST /app/installations/{id}/access_tokens +pub async fn create_access_token( + State(state): State, + Path(id): Path, + headers: HeaderMap, + Json(body): Json, +) -> impl IntoResponse { + let token = match extract_bearer_token(&headers) { + Some(t) => t, + None => { + return ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({"message": "Unauthorized"})), + ) + .into_response(); + } + }; + + let mut state = state.write().await; + + // Verify JWT + let mut authenticated_app_id: Option = None; + for app in state.apps.values() { + if let Ok(app_id) = verify_app_jwt(&token, &app.public_key_pem) { + if app_id == app.config.app_id { + authenticated_app_id = Some(app_id); + break; + } + } + } + + let app_id = match authenticated_app_id { + Some(id) => id, + None => { + return ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({"message": "Unauthorized"})), + ) + .into_response(); + } + }; + + let installation = match state.find_installation_by_id(id) { + Some(i) => i.clone(), + None => { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"message": "Not Found"})), + ) + .into_response(); + } + }; + + // Validate requested repos are in installation's repo list + let requested_repos: Vec = body + .get("repositories") + .and_then(|r| r.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default(); + + let permissions = body + .get("permissions") + .cloned() + .unwrap_or(serde_json::json!({})); + + if !requested_repos.is_empty() { + for repo in &requested_repos { + if !installation.repositories.contains(repo) { + return ( + StatusCode::UNPROCESSABLE_ENTITY, + Json(serde_json::json!({ + "message": format!("Repository '{}' is not part of this installation", repo) + })), + ) + .into_response(); + } + } + } + + let repos_for_token = if requested_repos.is_empty() { + installation.repositories.clone() + } else { + requested_repos + }; + + let access_token = state.generate_access_token(&app_id, id, repos_for_token, permissions); + + ( + StatusCode::CREATED, + Json(serde_json::json!({ + "token": access_token, + "expires_at": "2099-01-01T00:00:00Z", + })), + ) + .into_response() +} + +#[cfg(test)] +mod tests { + use crate::server::TestServer; + use crate::state::{AppConfig, AppState}; + + fn test_rsa_key() -> String { + use std::process::Command; + let output = Command::new("openssl") + .args([ + "genpkey", + "-algorithm", + "RSA", + "-pkeyopt", + "rsa_keygen_bits:2048", + ]) + .output() + .expect("openssl should be available"); + assert!(output.status.success()); + String::from_utf8(output.stdout).unwrap() + } + + fn sign_test_jwt(app_id: &str, private_key_pem: &str) -> String { + use jsonwebtoken::{Algorithm, EncodingKey, Header, encode}; + use serde::Serialize; + + #[derive(Serialize)] + struct Claims { + iss: String, + iat: i64, + exp: i64, + } + + let now = chrono::Utc::now().timestamp(); + let claims = Claims { + iss: app_id.to_string(), + iat: now - 60, + exp: now + 600, + }; + let key = EncodingKey::from_rsa_pem(private_key_pem.as_bytes()).unwrap(); + encode(&Header::new(Algorithm::RS256), &claims, &key).unwrap() + } + + #[tokio::test] + async fn get_installation_returns_id() { + let pem = test_rsa_key(); + let mut state = AppState::new(); + state.register_app(AppConfig { + app_id: "100".to_string(), + slug: "test-app".to_string(), + owner_login: "owner".to_string(), + public: true, + private_key_pem: pem.clone(), + webhook_secret: None, + }); + state.add_installation("100", "owner", vec!["repo".to_string()], false); + state.add_repository("owner", "repo", vec!["main".to_string()], false); + let server = TestServer::start(state).await; + + let jwt = sign_test_jwt("100", &pem); + let client = reqwest::Client::new(); + let resp = client + .get(&format!("{}/repos/owner/repo/installation", server.url())) + .header("Authorization", format!("Bearer {jwt}")) + .header("Accept", "application/vnd.github+json") + .header("User-Agent", "test-agent") + .send() + .await + .unwrap(); + + assert_eq!(resp.status(), 200); + let body: serde_json::Value = resp.json().await.unwrap(); + assert!(body["id"].as_u64().is_some()); + } + + #[tokio::test] + async fn get_installation_returns_404_when_not_installed() { + let pem = test_rsa_key(); + let mut state = AppState::new(); + state.register_app(AppConfig { + app_id: "100".to_string(), + slug: "test-app".to_string(), + owner_login: "owner".to_string(), + public: true, + private_key_pem: pem.clone(), + webhook_secret: None, + }); + // No installation added + let server = TestServer::start(state).await; + + let jwt = sign_test_jwt("100", &pem); + let client = reqwest::Client::new(); + let resp = client + .get(&format!("{}/repos/owner/repo/installation", server.url())) + .header("Authorization", format!("Bearer {jwt}")) + .send() + .await + .unwrap(); + + assert_eq!(resp.status(), 404); + server.shutdown().await; + } + + #[tokio::test] + async fn get_installation_returns_403_when_suspended() { + let pem = test_rsa_key(); + let mut state = AppState::new(); + state.register_app(AppConfig { + app_id: "100".to_string(), + slug: "test-app".to_string(), + owner_login: "owner".to_string(), + public: true, + private_key_pem: pem.clone(), + webhook_secret: None, + }); + state.add_installation("100", "owner", vec!["repo".to_string()], true); // suspended + let server = TestServer::start(state).await; + + let jwt = sign_test_jwt("100", &pem); + let client = reqwest::Client::new(); + let resp = client + .get(&format!("{}/repos/owner/repo/installation", server.url())) + .header("Authorization", format!("Bearer {jwt}")) + .send() + .await + .unwrap(); + + assert_eq!(resp.status(), 403); + server.shutdown().await; + } + + #[tokio::test] + async fn create_access_token_returns_201() { + let pem = test_rsa_key(); + let mut state = AppState::new(); + state.register_app(AppConfig { + app_id: "100".to_string(), + slug: "test-app".to_string(), + owner_login: "owner".to_string(), + public: true, + private_key_pem: pem.clone(), + webhook_secret: None, + }); + let install_id = state.add_installation("100", "owner", vec!["repo".to_string()], false); + let server = TestServer::start(state).await; + + let jwt = sign_test_jwt("100", &pem); + let client = reqwest::Client::new(); + let resp = client + .post(&format!( + "{}/app/installations/{install_id}/access_tokens", + server.url() + )) + .header("Authorization", format!("Bearer {jwt}")) + .header("Accept", "application/vnd.github+json") + .json(&serde_json::json!({ + "repositories": ["repo"], + "permissions": {"contents": "write"} + })) + .send() + .await + .unwrap(); + + assert_eq!(resp.status(), 201); + let body: serde_json::Value = resp.json().await.unwrap(); + let token = body["token"].as_str().unwrap(); + assert!(token.starts_with("ghs_")); + } + + #[tokio::test] + async fn create_access_token_returns_422_for_unauthorized_repo() { + let pem = test_rsa_key(); + let mut state = AppState::new(); + state.register_app(AppConfig { + app_id: "100".to_string(), + slug: "test-app".to_string(), + owner_login: "owner".to_string(), + public: true, + private_key_pem: pem.clone(), + webhook_secret: None, + }); + let install_id = state.add_installation("100", "owner", vec!["repo".to_string()], false); + let server = TestServer::start(state).await; + + let jwt = sign_test_jwt("100", &pem); + let client = reqwest::Client::new(); + let resp = client + .post(&format!( + "{}/app/installations/{install_id}/access_tokens", + server.url() + )) + .header("Authorization", format!("Bearer {jwt}")) + .json(&serde_json::json!({ + "repositories": ["not-authorized-repo"], + "permissions": {"contents": "write"} + })) + .send() + .await + .unwrap(); + + assert_eq!(resp.status(), 422); + server.shutdown().await; + } +} diff --git a/test/twin/github/src/handlers/manifests.rs b/test/twin/github/src/handlers/manifests.rs new file mode 100644 index 000000000..6221c6b91 --- /dev/null +++ b/test/twin/github/src/handlers/manifests.rs @@ -0,0 +1,98 @@ +use axum::Json; +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::response::IntoResponse; + +use crate::server::SharedState; + +/// POST /app-manifests/{code}/conversions +pub async fn convert_manifest( + State(state): State, + Path(code): Path, +) -> impl IntoResponse { + let state = state.read().await; + + match state.manifest_conversions.get(&code) { + Some(conversion) => ( + StatusCode::OK, + Json(serde_json::json!({ + "id": conversion.app_id, + "slug": conversion.slug, + "client_id": conversion.client_id, + "client_secret": conversion.client_secret, + "webhook_secret": conversion.webhook_secret, + "pem": conversion.pem, + })), + ) + .into_response(), + None => ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"message": "Not Found"})), + ) + .into_response(), + } +} + +#[cfg(test)] +mod tests { + use crate::server::TestServer; + use crate::state::{AppState, ManifestConversion}; + + #[tokio::test] + async fn manifest_conversion_returns_app_credentials() { + let mut state = AppState::new(); + state.manifest_conversions.insert( + "test-code".to_string(), + ManifestConversion { + code: "test-code".to_string(), + app_id: 99, + slug: "test-dev".to_string(), + client_id: "Iv1.abc123".to_string(), + client_secret: "secret123".to_string(), + webhook_secret: Some("whsecret".to_string()), + pem: "-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----" + .to_string(), + }, + ); + let server = TestServer::start(state).await; + + let client = reqwest::Client::new(); + let resp = client + .post(&format!( + "{}/app-manifests/test-code/conversions", + server.url() + )) + .header("Accept", "application/vnd.github+json") + .send() + .await + .unwrap(); + + assert_eq!(resp.status(), 200); + let body: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(body["id"], 99); + assert_eq!(body["slug"], "test-dev"); + assert_eq!(body["client_id"], "Iv1.abc123"); + assert!(body["pem"].as_str().is_some()); + + server.shutdown().await; + } + + #[tokio::test] + async fn manifest_conversion_unknown_code_returns_404() { + let state = AppState::new(); + let server = TestServer::start(state).await; + + let client = reqwest::Client::new(); + let resp = client + .post(&format!( + "{}/app-manifests/unknown/conversions", + server.url() + )) + .send() + .await + .unwrap(); + + assert_eq!(resp.status(), 404); + server.shutdown().await; + } +} diff --git a/test/twin/github/src/handlers/mod.rs b/test/twin/github/src/handlers/mod.rs new file mode 100644 index 000000000..c2793953d --- /dev/null +++ b/test/twin/github/src/handlers/mod.rs @@ -0,0 +1,74 @@ +pub mod app; +pub mod branches; +pub mod git; +pub mod graphql; +pub mod installations; +pub mod manifests; +pub mod pulls; +pub mod releases; + +use crate::server::SharedState; +use axum::Router; +use axum::routing::{get, patch, post, put}; + +pub fn build_router(state: SharedState) -> Router { + Router::new() + // App endpoints + .route("/app", get(app::get_app)) + .route("/apps/{slug}", get(app::get_app_by_slug)) + .route("/app/hook/config", patch(app::patch_webhook_config)) + // Installation endpoints + .route( + "/repos/{owner}/{repo}/installation", + get(installations::get_installation), + ) + .route( + "/app/installations/{id}/access_tokens", + post(installations::create_access_token), + ) + // Branch endpoints + .route( + "/repos/{owner}/{repo}/branches/{branch}", + get(branches::get_branch), + ) + // Pull request endpoints + .route( + "/repos/{owner}/{repo}/pulls", + post(pulls::create_pull_request), + ) + .route( + "/repos/{owner}/{repo}/pulls/{number}", + get(pulls::get_pull_request), + ) + .route( + "/repos/{owner}/{repo}/pulls/{number}", + patch(pulls::update_pull_request), + ) + .route( + "/repos/{owner}/{repo}/pulls/{number}/merge", + put(pulls::merge_pull_request), + ) + // Manifest conversion + .route( + "/app-manifests/{code}/conversions", + post(manifests::convert_manifest), + ) + // Releases + .route( + "/repos/{owner}/{repo}/releases/latest", + get(releases::get_latest_release), + ) + // GraphQL + .route("/graphql", post(graphql::handle_graphql)) + // Git smart HTTP transport routes + .route("/{owner}/{repo}/info/refs", get(git::git_info_refs)) + .route( + "/{owner}/{repo}/git-upload-pack", + post(git::git_upload_pack), + ) + .route( + "/{owner}/{repo}/git-receive-pack", + post(git::git_receive_pack), + ) + .with_state(state) +} diff --git a/test/twin/github/src/handlers/pulls.rs b/test/twin/github/src/handlers/pulls.rs new file mode 100644 index 000000000..eb3aac4ef --- /dev/null +++ b/test/twin/github/src/handlers/pulls.rs @@ -0,0 +1,582 @@ +use axum::Json; +use axum::extract::{Path, State}; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::IntoResponse; + +use crate::auth::{ + BearerTokenError, InstallationTokenAccessError, authorize_installation_token, + ensure_repo_permission, +}; +use crate::server::SharedState; +use crate::state::{PermissionLevel, PullRequest, TokenPermission}; + +fn bearer_token_error_response(error: BearerTokenError) -> axum::response::Response { + match error { + BearerTokenError::Missing => ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({"message": "Unauthorized"})), + ) + .into_response(), + BearerTokenError::Invalid => ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({"message": "Bad credentials"})), + ) + .into_response(), + } +} + +fn repo_permission_error_response(error: InstallationTokenAccessError) -> axum::response::Response { + match error { + InstallationTokenAccessError::RepoNotAccessible => ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"message": "Not Found"})), + ) + .into_response(), + InstallationTokenAccessError::PermissionDenied => ( + StatusCode::FORBIDDEN, + Json(serde_json::json!({"message": "Resource not accessible by integration"})), + ) + .into_response(), + } +} + +/// POST /repos/{owner}/{repo}/pulls +pub async fn create_pull_request( + State(state): State, + Path((owner, repo)): Path<(String, String)>, + headers: HeaderMap, + Json(body): Json, +) -> impl IntoResponse { + let mut state = state.write().await; + let token = match authorize_installation_token(&headers, &state) { + Ok(token) => token, + Err(error) => return bearer_token_error_response(error), + }; + if let Err(error) = ensure_repo_permission( + &token, + &repo, + TokenPermission::PullRequests, + PermissionLevel::Write, + ) { + return repo_permission_error_response(error); + } + + let title = body + .get("title") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let head = body + .get("head") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let base = body + .get("base") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let pr_body = body + .get("body") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let draft = body.get("draft").and_then(|v| v.as_bool()).unwrap_or(false); + + let number = state.next_pr_number; + state.next_pr_number += 1; + let node_id = format!("PR_{}", uuid::Uuid::new_v4().to_string().replace('-', "")); + let now = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); + let html_url = format!("https://github.com/{owner}/{repo}/pull/{number}"); + + let pr = PullRequest { + number, + node_id: node_id.clone(), + title: title.clone(), + body: pr_body.clone(), + state: "open".to_string(), + draft, + mergeable: true, + additions: 10, + deletions: 5, + changed_files: 2, + html_url: html_url.clone(), + user_login: "test-bot[bot]".to_string(), + head_ref: head.clone(), + base_ref: base.clone(), + created_at: now.clone(), + updated_at: now.clone(), + auto_merge: None, + }; + + state + .pull_requests + .entry((owner.clone(), repo.clone())) + .or_default() + .push(pr); + + ( + StatusCode::CREATED, + Json(serde_json::json!({ + "number": number, + "node_id": node_id, + "title": title, + "body": pr_body, + "state": "open", + "draft": draft, + "html_url": html_url, + "user": { "login": "test-bot[bot]" }, + "head": { "ref": head }, + "base": { "ref": base }, + "created_at": now, + "updated_at": now, + })), + ) + .into_response() +} + +/// GET /repos/{owner}/{repo}/pulls/{number} +pub async fn get_pull_request( + State(state): State, + Path((owner, repo, number)): Path<(String, String, u64)>, + headers: HeaderMap, +) -> impl IntoResponse { + let state = state.read().await; + let token = match authorize_installation_token(&headers, &state) { + Ok(token) => token, + Err(error) => return bearer_token_error_response(error), + }; + if let Err(error) = ensure_repo_permission( + &token, + &repo, + TokenPermission::PullRequests, + PermissionLevel::Read, + ) { + return repo_permission_error_response(error); + } + + let key = (owner, repo); + if let Some(prs) = state.pull_requests.get(&key) { + if let Some(pr) = prs.iter().find(|p| p.number == number) { + return (StatusCode::OK, Json(pr_to_json(pr))).into_response(); + } + } + + ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"message": "Not Found"})), + ) + .into_response() +} + +/// PATCH /repos/{owner}/{repo}/pulls/{number} +pub async fn update_pull_request( + State(state): State, + Path((owner, repo, number)): Path<(String, String, u64)>, + headers: HeaderMap, + Json(body): Json, +) -> impl IntoResponse { + let mut state = state.write().await; + let token = match authorize_installation_token(&headers, &state) { + Ok(token) => token, + Err(error) => return bearer_token_error_response(error), + }; + if let Err(error) = ensure_repo_permission( + &token, + &repo, + TokenPermission::PullRequests, + PermissionLevel::Write, + ) { + return repo_permission_error_response(error); + } + + let key = (owner, repo); + if let Some(prs) = state.pull_requests.get_mut(&key) { + if let Some(pr) = prs.iter_mut().find(|p| p.number == number) { + if let Some(new_state) = body.get("state").and_then(|v| v.as_str()) { + pr.state = new_state.to_string(); + } + if let Some(new_title) = body.get("title").and_then(|v| v.as_str()) { + pr.title = new_title.to_string(); + } + if let Some(new_body) = body.get("body").and_then(|v| v.as_str()) { + pr.body = new_body.to_string(); + } + pr.updated_at = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); + return (StatusCode::OK, Json(pr_to_json(pr))).into_response(); + } + } + + ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"message": "Not Found"})), + ) + .into_response() +} + +/// PUT /repos/{owner}/{repo}/pulls/{number}/merge +pub async fn merge_pull_request( + State(state): State, + Path((owner, repo, number)): Path<(String, String, u64)>, + headers: HeaderMap, + Json(_body): Json, +) -> impl IntoResponse { + let mut state = state.write().await; + let token = match authorize_installation_token(&headers, &state) { + Ok(token) => token, + Err(error) => return bearer_token_error_response(error), + }; + if let Err(error) = ensure_repo_permission( + &token, + &repo, + TokenPermission::PullRequests, + PermissionLevel::Write, + ) { + return repo_permission_error_response(error); + } + + let key = (owner, repo); + if let Some(prs) = state.pull_requests.get_mut(&key) { + if let Some(pr) = prs.iter_mut().find(|p| p.number == number) { + if pr.state != "open" || !pr.mergeable { + return ( + StatusCode::METHOD_NOT_ALLOWED, + Json(serde_json::json!({ + "message": "Pull request is not mergeable" + })), + ) + .into_response(); + } + pr.state = "closed".to_string(); + pr.mergeable = false; + pr.updated_at = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); + return ( + StatusCode::OK, + Json(serde_json::json!({ + "sha": "abc123", + "merged": true, + "message": "Pull Request successfully merged" + })), + ) + .into_response(); + } + } + + ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"message": "Not Found"})), + ) + .into_response() +} + +fn pr_to_json(pr: &PullRequest) -> serde_json::Value { + let mut json = serde_json::json!({ + "number": pr.number, + "node_id": pr.node_id, + "title": pr.title, + "body": pr.body, + "state": pr.state, + "draft": pr.draft, + "mergeable": pr.mergeable, + "additions": pr.additions, + "deletions": pr.deletions, + "changed_files": pr.changed_files, + "html_url": pr.html_url, + "user": { "login": pr.user_login }, + "head": { "ref": pr.head_ref }, + "base": { "ref": pr.base_ref }, + "created_at": pr.created_at, + "updated_at": pr.updated_at, + }); + + if let Some(auto_merge) = &pr.auto_merge { + json["auto_merge"] = serde_json::json!({ + "enabled_at": auto_merge.enabled_at, + "merge_method": auto_merge.merge_method, + }); + } + + json +} + +#[cfg(test)] +mod tests { + use crate::server::TestServer; + use crate::state::{AppConfig, AppState}; + + fn test_rsa_key() -> String { + use std::process::Command; + let output = Command::new("openssl") + .args([ + "genpkey", + "-algorithm", + "RSA", + "-pkeyopt", + "rsa_keygen_bits:2048", + ]) + .output() + .expect("openssl should be available"); + assert!(output.status.success()); + String::from_utf8(output.stdout).unwrap() + } + + fn sign_test_jwt(app_id: &str, private_key_pem: &str) -> String { + use jsonwebtoken::{Algorithm, EncodingKey, Header, encode}; + use serde::Serialize; + + #[derive(Serialize)] + struct Claims { + iss: String, + iat: i64, + exp: i64, + } + + let now = chrono::Utc::now().timestamp(); + let claims = Claims { + iss: app_id.to_string(), + iat: now - 60, + exp: now + 600, + }; + let key = EncodingKey::from_rsa_pem(private_key_pem.as_bytes()).unwrap(); + encode(&Header::new(Algorithm::RS256), &claims, &key).unwrap() + } + + async fn get_installation_token( + client: &reqwest::Client, + jwt: &str, + owner: &str, + repo: &str, + base_url: &str, + ) -> String { + let resp = client + .get(&format!("{base_url}/repos/{owner}/{repo}/installation")) + .header("Authorization", format!("Bearer {jwt}")) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + let body: serde_json::Value = resp.json().await.unwrap(); + let install_id = body["id"].as_u64().unwrap(); + + let resp = client + .post(&format!( + "{base_url}/app/installations/{install_id}/access_tokens" + )) + .header("Authorization", format!("Bearer {jwt}")) + .json(&serde_json::json!({ + "repositories": [repo], + "permissions": {"contents": "write", "pull_requests": "write"} + })) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 201); + let body: serde_json::Value = resp.json().await.unwrap(); + body["token"].as_str().unwrap().to_string() + } + + async fn setup_and_get_token( + state: &mut AppState, + pem: &str, + ) -> (TestServer, reqwest::Client, String) { + state.register_app(AppConfig { + app_id: "100".to_string(), + slug: "test-app".to_string(), + owner_login: "owner".to_string(), + public: true, + private_key_pem: pem.to_string(), + webhook_secret: None, + }); + state.add_installation("100", "owner", vec!["repo".to_string()], false); + state.add_repository( + "owner", + "repo", + vec!["main".to_string(), "feature".to_string()], + false, + ); + let server = TestServer::start(state.clone()).await; + + let jwt = sign_test_jwt("100", pem); + let client = reqwest::Client::new(); + let token = get_installation_token(&client, &jwt, "owner", "repo", server.url()).await; + + (server, client, token) + } + + #[tokio::test] + async fn create_pr_returns_201() { + let pem = test_rsa_key(); + let mut state = AppState::new(); + let (server, client, token) = setup_and_get_token(&mut state, &pem).await; + + let resp = client + .post(&format!("{}/repos/owner/repo/pulls", server.url())) + .header("Authorization", format!("Bearer {token}")) + .header("Accept", "application/vnd.github+json") + .json(&serde_json::json!({ + "title": "Test PR", + "head": "feature", + "base": "main", + "body": "PR body", + "draft": false, + })) + .send() + .await + .unwrap(); + + assert_eq!(resp.status(), 201); + let body: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(body["title"], "Test PR"); + assert!(body["number"].as_u64().is_some()); + assert!(body["html_url"].as_str().is_some()); + assert!(body["node_id"].as_str().is_some()); + server.shutdown().await; + } + + #[tokio::test] + async fn get_pr_returns_detail() { + let pem = test_rsa_key(); + let mut state = AppState::new(); + let (server, client, token) = setup_and_get_token(&mut state, &pem).await; + + // Create a PR first + let create_resp = client + .post(&format!("{}/repos/owner/repo/pulls", server.url())) + .header("Authorization", format!("Bearer {token}")) + .json(&serde_json::json!({ + "title": "Test PR", + "head": "feature", + "base": "main", + "body": "Body text", + "draft": true, + })) + .send() + .await + .unwrap(); + let created: serde_json::Value = create_resp.json().await.unwrap(); + let number = created["number"].as_u64().unwrap(); + + // Now get it + let resp = client + .get(&format!("{}/repos/owner/repo/pulls/{number}", server.url())) + .header("Authorization", format!("Bearer {token}")) + .send() + .await + .unwrap(); + + assert_eq!(resp.status(), 200); + let body: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(body["number"], number); + assert_eq!(body["title"], "Test PR"); + assert_eq!(body["state"], "open"); + assert_eq!(body["draft"], true); + assert!(body["mergeable"].is_boolean()); + assert!(body["additions"].is_u64()); + assert!(body["deletions"].is_u64()); + assert!(body["changed_files"].is_u64()); + assert_eq!(body["user"]["login"], "test-bot[bot]"); + assert_eq!(body["head"]["ref"], "feature"); + assert_eq!(body["base"]["ref"], "main"); + assert!(body["created_at"].is_string()); + assert!(body["updated_at"].is_string()); + + server.shutdown().await; + } + + #[tokio::test] + async fn merge_pr_returns_200() { + let pem = test_rsa_key(); + let mut state = AppState::new(); + let (server, client, token) = setup_and_get_token(&mut state, &pem).await; + + // Create a PR + let create_resp = client + .post(&format!("{}/repos/owner/repo/pulls", server.url())) + .header("Authorization", format!("Bearer {token}")) + .json(&serde_json::json!({ + "title": "Test PR", "head": "feature", "base": "main", "body": "", "draft": false, + })) + .send() + .await + .unwrap(); + let created: serde_json::Value = create_resp.json().await.unwrap(); + let number = created["number"].as_u64().unwrap(); + + // Merge it + let merge_resp = client + .put(&format!( + "{}/repos/owner/repo/pulls/{number}/merge", + server.url() + )) + .header("Authorization", format!("Bearer {token}")) + .json(&serde_json::json!({ "merge_method": "squash" })) + .send() + .await + .unwrap(); + assert_eq!(merge_resp.status(), 200); + + // Verify state changed + let get_resp = client + .get(&format!("{}/repos/owner/repo/pulls/{number}", server.url())) + .header("Authorization", format!("Bearer {token}")) + .send() + .await + .unwrap(); + let body: serde_json::Value = get_resp.json().await.unwrap(); + assert_eq!(body["state"], "closed"); + + server.shutdown().await; + } + + #[tokio::test] + async fn close_pr_returns_200() { + let pem = test_rsa_key(); + let mut state = AppState::new(); + let (server, client, token) = setup_and_get_token(&mut state, &pem).await; + + // Create a PR + let create_resp = client + .post(&format!("{}/repos/owner/repo/pulls", server.url())) + .header("Authorization", format!("Bearer {token}")) + .json(&serde_json::json!({ + "title": "Test PR", "head": "feature", "base": "main", "body": "", "draft": false, + })) + .send() + .await + .unwrap(); + let created: serde_json::Value = create_resp.json().await.unwrap(); + let number = created["number"].as_u64().unwrap(); + + // Close it + let close_resp = client + .patch(&format!("{}/repos/owner/repo/pulls/{number}", server.url())) + .header("Authorization", format!("Bearer {token}")) + .json(&serde_json::json!({ "state": "closed" })) + .send() + .await + .unwrap(); + assert_eq!(close_resp.status(), 200); + + server.shutdown().await; + } + + #[tokio::test] + async fn merge_nonexistent_pr_returns_404() { + let pem = test_rsa_key(); + let mut state = AppState::new(); + let (server, client, token) = setup_and_get_token(&mut state, &pem).await; + + let resp = client + .put(&format!( + "{}/repos/owner/repo/pulls/999/merge", + server.url() + )) + .header("Authorization", format!("Bearer {token}")) + .json(&serde_json::json!({ "merge_method": "squash" })) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 404); + server.shutdown().await; + } +} diff --git a/test/twin/github/src/handlers/releases.rs b/test/twin/github/src/handlers/releases.rs new file mode 100644 index 000000000..7cf78d16b --- /dev/null +++ b/test/twin/github/src/handlers/releases.rs @@ -0,0 +1,75 @@ +use axum::Json; +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::response::IntoResponse; + +use crate::server::SharedState; + +/// GET /repos/{owner}/{repo}/releases/latest +pub async fn get_latest_release( + State(state): State, + Path((owner, repo)): Path<(String, String)>, +) -> impl IntoResponse { + let state = state.read().await; + + match state.releases.get(&(owner, repo)) { + Some(release) => ( + StatusCode::OK, + Json(serde_json::json!({ + "tag_name": release.tag_name, + })), + ) + .into_response(), + None => ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"message": "Not Found"})), + ) + .into_response(), + } +} + +#[cfg(test)] +mod tests { + use crate::server::TestServer; + use crate::state::{AppState, Release}; + + #[tokio::test] + async fn latest_release_returns_tag() { + let mut state = AppState::new(); + state.releases.insert( + ("test-org".to_string(), "test-project".to_string()), + Release { + tag_name: "v0.176.2".to_string(), + }, + ); + let server = TestServer::start(state).await; + + let resp = reqwest::get(&format!( + "{}/repos/test-org/test-project/releases/latest", + server.url() + )) + .await + .unwrap(); + + assert_eq!(resp.status(), 200); + let body: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(body["tag_name"], "v0.176.2"); + server.shutdown().await; + } + + #[tokio::test] + async fn latest_release_404_when_none() { + let state = AppState::new(); + let server = TestServer::start(state).await; + + let resp = reqwest::get(&format!( + "{}/repos/owner/repo/releases/latest", + server.url() + )) + .await + .unwrap(); + + assert_eq!(resp.status(), 404); + server.shutdown().await; + } +} diff --git a/test/twin/github/src/lib.rs b/test/twin/github/src/lib.rs new file mode 100644 index 000000000..2c7d1e496 --- /dev/null +++ b/test/twin/github/src/lib.rs @@ -0,0 +1,15 @@ +#![allow( + clippy::absolute_paths, + clippy::manual_let_else, + clippy::redundant_closure_for_method_calls, + clippy::redundant_else +)] + +pub mod auth; +pub mod fixtures; +pub mod handlers; +pub mod server; +pub mod state; + +pub use server::TestServer; +pub use state::AppState; diff --git a/test/twin/github/src/server.rs b/test/twin/github/src/server.rs new file mode 100644 index 000000000..08f4e26ba --- /dev/null +++ b/test/twin/github/src/server.rs @@ -0,0 +1,84 @@ +use axum::Router; +use std::sync::Arc; +use tempfile::TempDir; +use tokio::net::TcpListener; +use tokio::sync::RwLock; + +use crate::handlers; +use crate::state::{AppState, init_git_repos}; + +pub type SharedState = Arc>; + +/// A running test server instance. +pub struct TestServer { + url: String, + shutdown_tx: Option>, + handle: Option>, + _git_root: TempDir, // Kept alive for the server's lifetime; cleaned up on drop +} + +impl TestServer { + /// Start the fake GitHub API server on a random port. + pub async fn start(mut state: AppState) -> Self { + let git_root = TempDir::new().expect("failed to create temp git root"); + init_git_repos(&mut state, git_root.path()).expect("failed to initialize git repos"); + + let shared_state: SharedState = Arc::new(RwLock::new(state)); + let router = build_router(shared_state); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let url = format!("http://127.0.0.1:{port}"); + + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); + + let handle = tokio::spawn(async move { + axum::serve(listener, router) + .with_graceful_shutdown(async { + let _ = shutdown_rx.await; + }) + .await + .ok(); + }); + + Self { + url, + shutdown_tx: Some(shutdown_tx), + handle: Some(handle), + _git_root: git_root, + } + } + + pub fn url(&self) -> &str { + &self.url + } + + pub async fn shutdown(mut self) { + if let Some(tx) = self.shutdown_tx.take() { + let _ = tx.send(()); + } + if let Some(handle) = self.handle.take() { + let _ = handle.await; + } + } +} + +pub fn build_router(state: SharedState) -> Router { + handlers::build_router(state) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn server_starts_and_responds() { + let state = AppState::new(); + let server = TestServer::start(state).await; + let resp = reqwest::get(&format!("{}/nonexistent", server.url())) + .await + .unwrap(); + assert_eq!(resp.status(), 404); + server.shutdown().await; + } +} diff --git a/test/twin/github/src/state.rs b/test/twin/github/src/state.rs new file mode 100644 index 000000000..60b148236 --- /dev/null +++ b/test/twin/github/src/state.rs @@ -0,0 +1,493 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Configuration for a registered GitHub App (user-facing input). +#[derive(Debug, Clone)] +pub struct AppConfig { + pub app_id: String, + pub slug: String, + pub owner_login: String, + pub public: bool, + pub private_key_pem: String, + pub webhook_secret: Option, +} + +/// Internal enriched app config with derived public key. +#[derive(Debug, Clone)] +pub struct RegisteredApp { + pub config: AppConfig, + /// Derived from `private_key_pem` during `register_app`. Used for JWT verification. + pub public_key_pem: String, +} + +/// An installation of a GitHub App on a specific owner. +#[derive(Debug, Clone)] +pub struct Installation { + pub id: u64, + pub app_id: String, + pub owner: String, + pub repositories: Vec, + pub suspended: bool, +} + +/// A repository in the fake. +#[derive(Debug, Clone)] +pub struct Repository { + pub owner: String, + pub name: String, + pub branches: Vec, + pub default_branch: String, + pub private: bool, + pub git_dir: Option, +} + +/// A pull request. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PullRequest { + pub number: u64, + pub node_id: String, + pub title: String, + pub body: String, + pub state: String, + pub draft: bool, + pub mergeable: bool, + pub additions: u64, + pub deletions: u64, + pub changed_files: u64, + pub html_url: String, + pub user_login: String, + pub head_ref: String, + pub base_ref: String, + pub created_at: String, + pub updated_at: String, + pub auto_merge: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AutoMerge { + pub enabled_at: String, + pub merge_method: String, +} + +/// A GitHub Projects V2 project. +#[derive(Debug, Clone)] +pub struct Project { + pub node_id: String, + pub number: u64, + pub owner: String, + pub owner_type: OwnerType, + pub status_field_id: String, + pub status_options: Vec, + pub items: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum OwnerType { + Organization, + User, +} + +#[derive(Debug, Clone)] +pub struct StatusOption { + pub id: String, + pub name: String, +} + +#[derive(Debug, Clone)] +pub struct ProjectItem { + pub id: String, + pub status: String, + pub content: IssueContent, +} + +#[derive(Debug, Clone)] +pub struct IssueContent { + pub id: String, + pub number: u64, + pub title: String, + pub body: String, + pub url: String, + pub created_at: String, + pub updated_at: String, + pub assignee_ids: Vec, + pub labels: Vec, +} + +/// A release. +#[derive(Debug, Clone)] +pub struct Release { + pub tag_name: String, +} + +/// An app manifest conversion record. +#[derive(Debug, Clone)] +pub struct ManifestConversion { + pub code: String, + pub app_id: i64, + pub slug: String, + pub client_id: String, + pub client_secret: String, + pub webhook_secret: Option, + pub pem: String, +} + +/// A comment on an issue. +#[derive(Debug, Clone)] +pub struct Comment { + pub issue_node_id: String, + pub body: String, +} + +/// Stores webhook configuration. +#[derive(Debug, Clone, Default)] +pub struct WebhookConfig { + pub url: Option, + pub content_type: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)] +pub enum PermissionLevel { + #[default] + None, + Read, + Write, +} + +impl PermissionLevel { + fn from_json(value: Option<&serde_json::Value>) -> Self { + match value.and_then(|value| value.as_str()) { + Some("read") => Self::Read, + Some("write") => Self::Write, + _ => Self::None, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TokenPermission { + Contents, + PullRequests, + Issues, + OrganizationProjects, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct TokenPermissions { + pub contents: PermissionLevel, + pub pull_requests: PermissionLevel, + pub issues: PermissionLevel, + pub organization_projects: PermissionLevel, +} + +impl TokenPermissions { + pub fn from_json(value: &serde_json::Value) -> Self { + Self { + contents: PermissionLevel::from_json(value.get("contents")), + pull_requests: PermissionLevel::from_json(value.get("pull_requests")), + issues: PermissionLevel::from_json(value.get("issues")), + organization_projects: PermissionLevel::from_json(value.get("organization_projects")), + } + } + + pub fn level(&self, permission: TokenPermission) -> PermissionLevel { + match permission { + TokenPermission::Contents => self.contents, + TokenPermission::PullRequests => self.pull_requests, + TokenPermission::Issues => self.issues, + TokenPermission::OrganizationProjects => self.organization_projects, + } + } +} + +/// Info about an active installation access token. +#[derive(Debug, Clone)] +pub struct TokenInfo { + pub app_id: String, + pub installation_id: u64, + pub repositories: Vec, + pub permissions: serde_json::Value, +} + +impl TokenInfo { + pub fn allows_repo(&self, repo: &str) -> bool { + self.repositories.iter().any(|allowed| allowed == repo) + } + + pub fn parsed_permissions(&self) -> TokenPermissions { + TokenPermissions::from_json(&self.permissions) + } + + pub fn allows(&self, permission: TokenPermission, required: PermissionLevel) -> bool { + self.parsed_permissions().level(permission) >= required + } +} + +/// Central in-memory state for the fake GitHub server. +#[derive(Debug, Clone)] +pub struct AppState { + pub apps: HashMap, + pub installations: Vec, + pub repositories: Vec, + pub pull_requests: HashMap<(String, String), Vec>, + pub active_tokens: HashMap, + pub projects: Vec, + pub releases: HashMap<(String, String), Release>, + pub manifest_conversions: HashMap, + pub comments: Vec, + pub webhook_config: WebhookConfig, + pub next_installation_id: u64, + pub next_pr_number: u64, + pub viewer_id: String, +} + +/// Derive the RSA public key PEM from a private key PEM using the openssl CLI. +/// +/// Panics if openssl is not available or the key is invalid. This is acceptable +/// because the fake server is test infrastructure and openssl is already required +/// by the test helpers that generate key pairs. +pub fn derive_public_key_pem(private_key_pem: &str) -> String { + use std::io::Write; + use std::process::{Command, Stdio}; + + let mut child = Command::new("openssl") + .args(["rsa", "-pubout"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("openssl must be available"); + child + .stdin + .take() + .unwrap() + .write_all(private_key_pem.as_bytes()) + .unwrap(); + let output = child.wait_with_output().unwrap(); + assert!(output.status.success(), "openssl rsa -pubout failed"); + String::from_utf8(output.stdout).unwrap() +} + +impl AppState { + pub fn new() -> Self { + Self { + apps: HashMap::new(), + installations: Vec::new(), + repositories: Vec::new(), + pull_requests: HashMap::new(), + active_tokens: HashMap::new(), + projects: Vec::new(), + releases: HashMap::new(), + manifest_conversions: HashMap::new(), + comments: Vec::new(), + webhook_config: WebhookConfig::default(), + next_installation_id: 1, + next_pr_number: 1, + viewer_id: "U_fakeviewer".to_string(), + } + } + + pub fn register_app(&mut self, config: AppConfig) { + let public_key_pem = derive_public_key_pem(&config.private_key_pem); + let app_id = config.app_id.clone(); + self.apps.insert( + app_id, + RegisteredApp { + config, + public_key_pem, + }, + ); + } + + pub fn add_installation( + &mut self, + app_id: &str, + owner: &str, + repos: Vec, + suspended: bool, + ) -> u64 { + let id = self.next_installation_id; + self.next_installation_id += 1; + self.installations.push(Installation { + id, + app_id: app_id.to_string(), + owner: owner.to_string(), + repositories: repos, + suspended, + }); + id + } + + pub fn add_repository( + &mut self, + owner: &str, + name: &str, + branches: Vec, + private: bool, + ) { + self.repositories.push(Repository { + owner: owner.to_string(), + name: name.to_string(), + branches, + default_branch: "main".to_string(), + private, + git_dir: None, + }); + } + + pub fn find_installation(&self, owner: &str, repo: &str) -> Option<&Installation> { + self.installations + .iter() + .find(|i| i.owner == owner && i.repositories.iter().any(|r| r == repo)) + } + + pub fn find_installation_by_id(&self, id: u64) -> Option<&Installation> { + self.installations.iter().find(|i| i.id == id) + } + + pub fn generate_access_token( + &mut self, + app_id: &str, + installation_id: u64, + repositories: Vec, + permissions: serde_json::Value, + ) -> String { + let token = format!("ghs_{}", uuid::Uuid::new_v4().to_string().replace('-', "")); + self.active_tokens.insert( + token.clone(), + TokenInfo { + app_id: app_id.to_string(), + installation_id, + repositories, + permissions, + }, + ); + token + } + + pub fn validate_token(&self, token: &str) -> Option<&TokenInfo> { + self.active_tokens.get(token) + } +} + +impl Default for AppState { + fn default() -> Self { + Self::new() + } +} + +/// Initialize a bare git repository at `{git_root}/{owner}/{repo}.git/`. +/// Returns the path to the bare repo directory. +pub fn init_bare_repo( + git_root: &std::path::Path, + owner: &str, + repo: &str, +) -> Result { + let repo_dir = git_root.join(owner).join(format!("{repo}.git")); + if repo_dir.exists() { + return Ok(repo_dir); + } + std::fs::create_dir_all(&repo_dir) + .map_err(|e| format!("failed to create git dir {}: {e}", repo_dir.display()))?; + let output = std::process::Command::new("git") + .args(["init", "--bare"]) + .arg(&repo_dir) + .output() + .map_err(|e| format!("failed to run git init: {e}"))?; + if !output.status.success() { + return Err(format!( + "git init --bare failed: {}", + String::from_utf8_lossy(&output.stderr) + )); + } + + // Enable http.receivepack so push works via git-http-backend + let output = std::process::Command::new("git") + .args(["config", "http.receivepack", "true"]) + .current_dir(&repo_dir) + .output() + .map_err(|e| format!("failed to configure git repo: {e}"))?; + if !output.status.success() { + return Err(format!( + "git config http.receivepack failed: {}", + String::from_utf8_lossy(&output.stderr) + )); + } + + Ok(repo_dir) +} + +/// Initialize bare git repos for all repositories in the state. +/// Sets each repository's `git_dir` field. +pub fn init_git_repos(state: &mut AppState, git_root: &std::path::Path) -> Result<(), String> { + for repo in &mut state.repositories { + let git_dir = init_bare_repo(git_root, &repo.owner, &repo.name)?; + repo.git_dir = Some(git_dir); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_state_has_no_apps() { + let state = AppState::new(); + assert!(state.apps.is_empty()); + } + + fn test_rsa_key() -> String { + use std::process::Command; + let output = Command::new("openssl") + .args([ + "genpkey", + "-algorithm", + "RSA", + "-pkeyopt", + "rsa_keygen_bits:2048", + ]) + .output() + .expect("openssl should be available"); + assert!(output.status.success()); + String::from_utf8(output.stdout).unwrap() + } + + #[test] + fn repository_has_private_field() { + let mut state = AppState::new(); + state.add_repository("owner", "private-repo", vec!["main".to_string()], true); + let repo = state + .repositories + .iter() + .find(|r| r.name == "private-repo") + .unwrap(); + assert!(repo.private); + } + + #[test] + fn git_root_initializes_bare_repo() { + let tmp = tempfile::tempdir().unwrap(); + let git_root = tmp.path().to_path_buf(); + let repo_git_dir = init_bare_repo(&git_root, "acme", "widgets").unwrap(); + assert!(repo_git_dir.join("HEAD").exists()); + assert!(repo_git_dir.join("objects").exists()); + } + + #[test] + fn can_register_app() { + let pem = test_rsa_key(); + let mut state = AppState::new(); + state.register_app(AppConfig { + app_id: "12345".to_string(), + slug: "test-app".to_string(), + owner_login: "test-owner".to_string(), + public: true, + private_key_pem: pem, + webhook_secret: Some("secret".to_string()), + }); + assert_eq!(state.apps.len(), 1); + assert_eq!(state.apps["12345"].config.slug, "test-app"); + // Verify public key was derived + assert!(state.apps["12345"].public_key_pem.contains("PUBLIC KEY")); + } +}