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.
This commit is contained in:
Bryan Helmkamp 2026-04-01 09:48:29 -04:00
parent a496c243f4
commit fea64a105b
No known key found for this signature in database
27 changed files with 4877 additions and 41 deletions

3
.gitignore vendored
View file

@ -10,4 +10,5 @@ __pycache__
.ai/plans
.ai/comments
.ai/tmp
**/*.pending-snap
.worktrees/
**/*.pending-snap

21
Cargo.lock generated
View file

@ -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"

View file

@ -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" }

View file

@ -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"] }

View file

@ -403,6 +403,7 @@ pub async fn create_pull_request(
title: &str,
body: &str,
draft: bool,
base_url: &str,
) -> Result<CreatedPullRequest, String> {
#[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<String>, Option<String>), 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<String, String> {
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()),

View file

@ -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;
}

View file

@ -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 {}",

View file

@ -21,3 +21,4 @@ reqwest = { workspace = true }
tempfile = "3"
tokio = { workspace = true }
twin-openai = { workspace = true }
twin-github = { workspace = true }

View file

@ -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<TwinOpenAi> = OnceCell::const_new();
/// Returns a shared twin-openai server, starting it on first call.

View file

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

View file

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

View file

@ -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(),

View file

@ -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"] }

View file

@ -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<String, String> {
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::<Claims>(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<String> {
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<TokenInfo, BearerTokenError> {
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<GraphqlActor, BearerTokenError> {
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());
}
}

View file

@ -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<FixtureApp>,
#[serde(default)]
pub installations: Vec<FixtureInstallation>,
#[serde(default)]
pub repositories: Vec<FixtureRepository>,
#[serde(default)]
pub pull_requests: Vec<FixturePullRequest>,
#[serde(default)]
pub active_tokens: Vec<FixtureActiveToken>,
#[serde(default)]
pub projects: Vec<FixtureProject>,
#[serde(default)]
pub releases: Vec<FixtureRelease>,
#[serde(default)]
pub manifest_conversions: Vec<FixtureManifestConversion>,
#[serde(default)]
pub comments: Vec<FixtureComment>,
#[serde(default)]
pub webhook_config: FixtureWebhookConfig,
pub next_installation_id: Option<u64>,
pub next_pr_number: Option<u64>,
pub viewer_id: Option<String>,
}
#[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<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct FixtureInstallation {
pub id: u64,
pub app_id: String,
pub owner: String,
#[serde(default)]
pub repositories: Vec<String>,
#[serde(default)]
pub suspended: bool,
}
#[derive(Debug, Clone, Deserialize)]
pub struct FixtureRepository {
pub owner: String,
pub name: String,
#[serde(default)]
pub branches: Vec<String>,
pub default_branch: Option<String>,
#[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<String>,
#[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<FixtureStatusOption>,
#[serde(default)]
pub items: Vec<FixtureProjectItem>,
}
#[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<String>,
#[serde(default)]
pub labels: Vec<String>,
}
#[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<String>,
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<String>,
pub content_type: Option<String>,
}
impl FixtureState {
pub fn load_from_path(path: impl AsRef<Path>) -> Result<Self, String> {
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<AppState, String> {
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::<HashMap<_, _>>();
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::<HashMap<_, _>>();
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<PullRequest>>) -> 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()
}
}
}

View file

@ -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<SharedState>, 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<SharedState>,
Path(slug): Path<String>,
) -> 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<SharedState>,
headers: HeaderMap,
Json(body): Json<serde_json::Value>,
) -> 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;
}
}

View file

@ -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<SharedState>,
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;
}
}

View file

@ -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<PathBuf, String> {
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<PermissionLevel> {
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<u8>,
) -> 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::<u16>() {
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<usize> {
haystack
.windows(needle.len())
.position(|window| window == needle)
}
// ---- Axum route handlers ----
#[derive(serde::Deserialize)]
pub struct ServiceQuery {
service: Option<String>,
}
/// Handler for GET /{owner}/{repo}/info/refs (works with and without .git suffix)
pub async fn git_info_refs(
State(state): State<SharedState>,
Path((owner, repo_with_suffix)): Path<(String, String)>,
Query(query): Query<ServiceQuery>,
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<SharedState>,
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<SharedState>,
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;
}
}

File diff suppressed because it is too large Load diff

View file

@ -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<SharedState>,
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<SharedState>,
Path(id): Path<u64>,
headers: HeaderMap,
Json(body): Json<serde_json::Value>,
) -> 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<String> = 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<String> = 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;
}
}

View file

@ -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<SharedState>,
Path(code): Path<String>,
) -> 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;
}
}

View file

@ -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)
}

View file

@ -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<SharedState>,
Path((owner, repo)): Path<(String, String)>,
headers: HeaderMap,
Json(body): Json<serde_json::Value>,
) -> 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<SharedState>,
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<SharedState>,
Path((owner, repo, number)): Path<(String, String, u64)>,
headers: HeaderMap,
Json(body): Json<serde_json::Value>,
) -> 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<SharedState>,
Path((owner, repo, number)): Path<(String, String, u64)>,
headers: HeaderMap,
Json(_body): Json<serde_json::Value>,
) -> 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;
}
}

View file

@ -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<SharedState>,
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;
}
}

View file

@ -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;

View file

@ -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<RwLock<AppState>>;
/// A running test server instance.
pub struct TestServer {
url: String,
shutdown_tx: Option<tokio::sync::oneshot::Sender<()>>,
handle: Option<tokio::task::JoinHandle<()>>,
_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;
}
}

View file

@ -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<String>,
}
/// 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<String>,
pub suspended: bool,
}
/// A repository in the fake.
#[derive(Debug, Clone)]
pub struct Repository {
pub owner: String,
pub name: String,
pub branches: Vec<String>,
pub default_branch: String,
pub private: bool,
pub git_dir: Option<std::path::PathBuf>,
}
/// 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<AutoMerge>,
}
#[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<StatusOption>,
pub items: Vec<ProjectItem>,
}
#[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<String>,
pub labels: Vec<String>,
}
/// 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<String>,
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<String>,
pub content_type: Option<String>,
}
#[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<String>,
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<String, RegisteredApp>,
pub installations: Vec<Installation>,
pub repositories: Vec<Repository>,
pub pull_requests: HashMap<(String, String), Vec<PullRequest>>,
pub active_tokens: HashMap<String, TokenInfo>,
pub projects: Vec<Project>,
pub releases: HashMap<(String, String), Release>,
pub manifest_conversions: HashMap<String, ManifestConversion>,
pub comments: Vec<Comment>,
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<String>,
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<String>,
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<String>,
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<std::path::PathBuf, String> {
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"));
}
}