mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
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:
parent
a496c243f4
commit
fea64a105b
27 changed files with 4877 additions and 41 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -10,4 +10,5 @@ __pycache__
|
|||
.ai/plans
|
||||
.ai/comments
|
||||
.ai/tmp
|
||||
**/*.pending-snap
|
||||
.worktrees/
|
||||
**/*.pending-snap
|
||||
|
|
|
|||
21
Cargo.lock
generated
21
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
235
lib/crates/fabro-github/tests/integration.rs
Normal file
235
lib/crates/fabro-github/tests/integration.rs
Normal 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;
|
||||
}
|
||||
|
|
@ -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 {}",
|
||||
|
|
|
|||
|
|
@ -21,3 +21,4 @@ reqwest = { workspace = true }
|
|||
tempfile = "3"
|
||||
tokio = { workspace = true }
|
||||
twin-openai = { workspace = true }
|
||||
twin-github = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
30
test/twin/github/Cargo.toml
Normal file
30
test/twin/github/Cargo.toml
Normal 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"] }
|
||||
174
test/twin/github/src/auth.rs
Normal file
174
test/twin/github/src/auth.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
556
test/twin/github/src/fixtures.rs
Normal file
556
test/twin/github/src/fixtures.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
260
test/twin/github/src/handlers/app.rs
Normal file
260
test/twin/github/src/handlers/app.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
244
test/twin/github/src/handlers/branches.rs
Normal file
244
test/twin/github/src/handlers/branches.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
407
test/twin/github/src/handlers/git.rs
Normal file
407
test/twin/github/src/handlers/git.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
1053
test/twin/github/src/handlers/graphql.rs
Normal file
1053
test/twin/github/src/handlers/graphql.rs
Normal file
File diff suppressed because it is too large
Load diff
379
test/twin/github/src/handlers/installations.rs
Normal file
379
test/twin/github/src/handlers/installations.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
98
test/twin/github/src/handlers/manifests.rs
Normal file
98
test/twin/github/src/handlers/manifests.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
74
test/twin/github/src/handlers/mod.rs
Normal file
74
test/twin/github/src/handlers/mod.rs
Normal 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)
|
||||
}
|
||||
582
test/twin/github/src/handlers/pulls.rs
Normal file
582
test/twin/github/src/handlers/pulls.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
75
test/twin/github/src/handlers/releases.rs
Normal file
75
test/twin/github/src/handlers/releases.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
15
test/twin/github/src/lib.rs
Normal file
15
test/twin/github/src/lib.rs
Normal 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;
|
||||
84
test/twin/github/src/server.rs
Normal file
84
test/twin/github/src/server.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
493
test/twin/github/src/state.rs
Normal file
493
test/twin/github/src/state.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue