fabro/lib/components/fabro-github/tests/integration.rs
Scott Werner 3ab584691d Drop the branch-head compatibility wrapper
`branch_head_sha` existed to keep callers compiling while the branch-head
lookup moved onto the repository reader. Its last caller now opens a
reader directly, so the wrapper only made the typed API worse: it joined
an already-validated owner and repo into a slug so the reader could split
them apart again, invented an "invalid repository coordinate" error for a
value validated upstream, opened a fresh credential session per call, and
flattened `RepositoryReadError` into `anyhow` while keeping one variant —
leaving callers unable to tell a rate limit from a rejected token.

Its integration test pinned the wrapper rather than the behavior. Replace
it with one that asserts the same 404-means-not-observable semantics
through `resolve_commit`, which is where that contract actually lives.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 15:43:08 -04:00

522 lines
16 KiB
Rust

use std::error::Error as _;
use fabro_github::{
GitHubAppCredentials, GitHubContext, GitHubCredentials, GitHubRepositoryReader,
InstallationToken, RepositoryReadError, 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::{GitHubAppOptions, GitHubAppState, TwinGitHub};
use fabro_types::GitHubRepositorySlug;
use fabro_types::settings::run::MergeStrategy;
const TEST_RSA_KEY: &str = include_str!("../src/testdata/rsa_private.pem");
const HEAD_SHA: &str = "1111111111111111111111111111111111111111";
const TAG_SHA: &str = "2222222222222222222222222222222222222222";
const UPPER_SHA: &str = "ABCDEFABCDEFABCDEFABCDEFABCDEFABCDEFABCD";
fn github_credentials() -> GitHubCredentials {
GitHubCredentials::App(GitHubAppCredentials {
app_id: "42".to_string(),
private_key_pem: TEST_RSA_KEY.to_string(),
slug: Some("test-app".to_string()),
})
}
fn standard_app_state() -> GitHubAppState {
let mut state = GitHubAppState::new();
state.register_app(GitHubAppOptions {
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.set_repository_ref("acme", "widgets", "heads/main", HEAD_SHA);
state.set_repository_ref("acme", "widgets", "heads/release", HEAD_SHA);
state.set_repository_ref("acme", "widgets", "tags/release", TAG_SHA);
state.set_repository_ref("acme", "widgets", "heads/fabro/run/123", HEAD_SHA);
state.set_repository_ref("acme", "widgets", "heads/uppercase", UPPER_SHA);
for (path, contents) in [
(
".fabro/workflows/build/workflow.toml",
b"name = \"build\"\n".to_vec(),
),
("dir/hello world#.txt", b"hello\n".to_vec()),
("invalid.txt", vec![0xff, 0xfe]),
("empty.txt", Vec::new()),
("five-bytes.txt", b"12345".to_vec()),
("nested/a b/%/é.toml", "unicode: 🦀\n".as_bytes().to_vec()),
] {
state.add_repository_file("acme", "widgets", HEAD_SHA, path, contents);
}
state
}
fn repository() -> GitHubRepositorySlug {
GitHubRepositorySlug::try_new("acme/widgets").expect("test repository slug should be valid")
}
/// Open a reader for the standard repository against `base_url`.
async fn open_reader(
base_url: &str,
credentials: &GitHubCredentials,
) -> Result<GitHubRepositoryReader, RepositoryReadError> {
GitHubRepositoryReader::open(
&GitHubContext::with_http_client(credentials, base_url, fabro_test::test_http_client()),
&repository(),
)
.await
}
#[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 ctx = &GitHubContext::new(&creds, &twin.base_url);
let created = create_pull_request(
ctx,
"acme",
"widgets",
"main",
"feature",
"Add widgets",
"PR body",
false,
)
.await
.unwrap();
let pr = get_pull_request(ctx, "acme", "widgets", created.number)
.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 ctx = &GitHubContext::new(&creds, &twin.base_url);
let created = create_pull_request(
ctx, "acme", "widgets", "main", "feature", "Merge me", "PR body", false,
)
.await
.unwrap();
merge_pull_request(
ctx,
"acme",
"widgets",
created.number,
MergeStrategy::Squash,
)
.await
.unwrap();
let pr = get_pull_request(ctx, "acme", "widgets", created.number)
.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 ctx = &GitHubContext::new(&creds, &twin.base_url);
let created = create_pull_request(
ctx, "acme", "widgets", "main", "feature", "Close me", "PR body", false,
)
.await
.unwrap();
close_pull_request(ctx, "acme", "widgets", created.number)
.await
.unwrap();
let pr = get_pull_request(ctx, "acme", "widgets", created.number)
.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 ctx = &GitHubContext::new(&creds, &twin.base_url);
let created = create_pull_request(
ctx,
"acme",
"widgets",
"main",
"feature",
"Auto merge me",
"PR body",
false,
)
.await
.unwrap();
enable_auto_merge(
ctx,
"acme",
"widgets",
&created.node_id,
MergeStrategy::Squash,
)
.await
.unwrap();
let GitHubCredentials::App(app_creds) = &creds else {
panic!("expected app credentials");
};
let jwt = sign_app_jwt(&app_creds.app_id, &app_creds.private_key_pem).unwrap();
let client = fabro_test::test_http_client();
let token =
create_installation_access_token_for_pr(&client, &jwt, "acme", "widgets", &twin.base_url)
.await
.unwrap();
let detail: serde_json::Value = fabro_test::test_http_client()
.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(
&GitHubContext::new(&creds, &twin.base_url),
"https://github.com/acme/widgets.git",
)
.await
.unwrap();
assert!(url.raw_string().starts_with("https://x-access-token:ghs_"));
assert!(url.raw_string().contains("github.com/acme/widgets.git"));
assert!(
url.redacted_string()
.starts_with("https://x-access-token:****@")
);
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(
&GitHubContext::new(&creds, &twin.base_url),
"https://gitlab.com/foo/bar",
)
.await
.unwrap_err();
assert!(error.to_string().contains("Not a GitHub HTTPS URL"));
twin.shutdown().await;
}
#[fabro_macros::e2e_test(twin)]
async fn repository_reader_reuses_one_app_token_for_ref_and_file_reads() {
let twin = TwinGitHub::start(standard_app_state()).await;
assert_eq!(twin.active_token_count().await, 0);
let creds = github_credentials();
let base_url = format!("{}/", twin.base_url);
let reader = open_reader(&base_url, &creds).await.unwrap();
assert_eq!(twin.active_token_count().await, 1);
assert_eq!(reader.resolve_commit("heads/main").await.unwrap(), HEAD_SHA);
assert_eq!(
reader
.read_utf8_file_at(HEAD_SHA, ".fabro/workflows/build/workflow.toml", 1024)
.await
.unwrap(),
"name = \"build\"\n"
);
assert_eq!(
reader
.read_utf8_file_at(HEAD_SHA, "dir/hello world#.txt", 1024)
.await
.unwrap(),
"hello\n"
);
let unicode_contents = "unicode: 🦀\n";
assert_eq!(
reader
.read_utf8_file_at(HEAD_SHA, "nested/a b/%/é.toml", unicode_contents.len(),)
.await
.unwrap(),
unicode_contents
);
assert!(matches!(
reader
.read_utf8_file_at(HEAD_SHA, "nested/a b/%/é.toml", unicode_contents.len() - 1,)
.await,
Err(RepositoryReadError::BodyTooLarge { .. })
));
assert_eq!(twin.active_token_count().await, 1);
twin.shutdown().await;
}
#[fabro_macros::e2e_test(twin)]
async fn repository_reader_preserves_exact_ref_namespaces() {
let twin = TwinGitHub::start(standard_app_state()).await;
let creds = github_credentials();
let reader = open_reader(&twin.base_url, &creds).await.unwrap();
assert_eq!(
reader.resolve_commit("heads/release").await.unwrap(),
HEAD_SHA
);
assert_eq!(
reader.resolve_commit("tags/release").await.unwrap(),
TAG_SHA
);
assert_eq!(
reader.resolve_commit("heads/fabro/run/123").await.unwrap(),
HEAD_SHA
);
assert_eq!(reader.resolve_commit(HEAD_SHA).await.unwrap(), HEAD_SHA);
assert_eq!(
reader.resolve_commit("heads/uppercase").await.unwrap(),
UPPER_SHA.to_ascii_lowercase()
);
assert!(matches!(
reader.resolve_commit("heads/missing").await,
Err(RepositoryReadError::NotFound {
operation: fabro_github::Operation::Revision,
})
));
twin.shutdown().await;
}
#[fabro_macros::e2e_test(twin)]
async fn repository_reader_classifies_file_failures_without_retaining_bytes() {
let twin = TwinGitHub::start(standard_app_state()).await;
let creds = github_credentials();
let reader = open_reader(&twin.base_url, &creds).await.unwrap();
assert!(matches!(
reader
.read_utf8_file_at(HEAD_SHA, ".fabro/workflows/build/workflow.toml", 4)
.await,
Err(RepositoryReadError::BodyTooLarge { max_bytes: 4 })
));
let error = reader
.read_utf8_file_at(HEAD_SHA, "invalid.txt", 16)
.await
.unwrap_err();
assert!(matches!(error, RepositoryReadError::InvalidUtf8 { .. }));
assert!(
error
.source()
.and_then(|source| source.downcast_ref::<std::str::Utf8Error>())
.is_some()
);
assert!(!format!("{error:?}").contains("255"));
let directory_error = reader
.read_utf8_file_at(HEAD_SHA, "dir", 1024)
.await
.unwrap_err();
assert!(matches!(
directory_error,
RepositoryReadError::ContentNotFile
));
assert!(!format!("{directory_error:?}").contains("[]"));
assert!(matches!(
reader
.read_utf8_file_at(HEAD_SHA, "missing.txt", 1024)
.await,
Err(RepositoryReadError::NotFound {
operation: fabro_github::Operation::Content,
})
));
assert_eq!(
reader
.read_utf8_file_at(HEAD_SHA, "five-bytes.txt", 5)
.await
.unwrap(),
"12345"
);
assert!(matches!(
reader
.read_utf8_file_at(HEAD_SHA, "five-bytes.txt", 4)
.await,
Err(RepositoryReadError::BodyTooLarge { max_bytes: 4 })
));
assert_eq!(
reader
.read_utf8_file_at(HEAD_SHA, "empty.txt", 0)
.await
.unwrap(),
""
);
twin.shutdown().await;
}
#[fabro_macros::e2e_test(twin)]
async fn static_repository_credentials_do_not_mint_tokens() {
let mut state = standard_app_state();
let static_token = state.generate_access_token(
"42",
1,
vec!["widgets".to_string()],
serde_json::json!({ "contents": "read" }),
);
let twin = TwinGitHub::start(state).await;
let initial_tokens = twin.active_token_count().await;
for credentials in [
GitHubCredentials::Pat(static_token.clone()),
GitHubCredentials::Installation(InstallationToken {
token: static_token.clone(),
expires_at: chrono::Utc::now() + chrono::Duration::hours(1),
}),
] {
let reader = open_reader(&twin.base_url, &credentials).await.unwrap();
assert_eq!(reader.resolve_commit("heads/main").await.unwrap(), HEAD_SHA);
assert_eq!(
reader
.read_utf8_file_at(HEAD_SHA, "five-bytes.txt", 5)
.await
.unwrap(),
"12345"
);
}
assert_eq!(twin.active_token_count().await, initial_tokens);
twin.shutdown().await;
}
#[fabro_macros::e2e_test(twin)]
async fn expired_installation_token_keeps_credential_error_source() {
let twin = TwinGitHub::start(standard_app_state()).await;
let credentials = GitHubCredentials::Installation(InstallationToken {
token: "ghs_expired".to_string(),
expires_at: chrono::Utc::now() - chrono::Duration::minutes(1),
});
let error = open_reader(&twin.base_url, &credentials)
.await
.err()
.expect("expired installation token should fail");
let RepositoryReadError::CredentialResolution { source } = error else {
panic!("expected credential resolution error");
};
assert!(source.to_string().contains("expired"));
twin.shutdown().await;
}
#[fabro_macros::e2e_test(twin)]
async fn branch_head_resolution_distinguishes_missing_from_present() {
let twin = TwinGitHub::start(standard_app_state()).await;
let credentials = github_credentials();
let reader = open_reader(&twin.base_url, &credentials).await.unwrap();
assert_eq!(
reader.resolve_commit("heads/release").await.unwrap(),
HEAD_SHA
);
// GitHub answers an absent branch with 404, which the reader reports as
// "not observable" rather than proving the branch does not exist.
assert!(matches!(
reader.resolve_commit("heads/missing").await,
Err(RepositoryReadError::NotFound {
operation: fabro_github::Operation::Revision,
})
));
twin.shutdown().await;
}
#[fabro_macros::e2e_test(twin)]
async fn repository_reader_keeps_auth_and_malformed_sha_failures_distinct() {
let mut state = standard_app_state();
state.set_repository_ref("acme", "widgets", "heads/malformed", "short-sha");
let denied_token =
state.generate_access_token("42", 1, vec!["widgets".to_string()], serde_json::json!({}));
let twin = TwinGitHub::start(state).await;
let invalid_credentials = GitHubCredentials::Pat("invalid-token".to_string());
let invalid_reader = open_reader(&twin.base_url, &invalid_credentials)
.await
.unwrap();
assert!(matches!(
invalid_reader.resolve_commit("heads/main").await,
Err(RepositoryReadError::AuthenticationRejected)
));
let denied_credentials = GitHubCredentials::Pat(denied_token);
let denied_reader = open_reader(&twin.base_url, &denied_credentials)
.await
.unwrap();
assert!(matches!(
denied_reader.resolve_commit("heads/main").await,
Err(RepositoryReadError::PermissionDenied)
));
let app_credentials = github_credentials();
let app_reader = open_reader(&twin.base_url, &app_credentials).await.unwrap();
assert!(matches!(
app_reader.resolve_commit("heads/malformed").await,
Err(RepositoryReadError::MalformedCommitSha)
));
twin.shutdown().await;
}