mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
122 lines
5 KiB
Rust
122 lines
5 KiB
Rust
//! Opt-in live GitHub App test for additional-repository access.
|
|
//!
|
|
//! Verifies against the real GitHub API that one installation token scoped
|
|
//! to the primary repository plus one declared additional repository grants
|
|
//! Git read access to both. Runs only in live mode with these variables set
|
|
//! (it skips clearly otherwise):
|
|
//!
|
|
//! - `FABRO_TEST_GITHUB_APP_ID` — GitHub App id
|
|
//! - `GITHUB_APP_PRIVATE_KEY` — App private key (PEM, or base64-encoded PEM)
|
|
//! - `FABRO_TEST_GITHUB_ORIGIN` — HTTPS origin URL of the primary repository
|
|
//! - `FABRO_TEST_GITHUB_ADDITIONAL_REPO` — an `owner/repository` slug the
|
|
//! installation can see, ideally private, sharing the origin's owner
|
|
//!
|
|
//! The repositories come from the environment so no private slug is baked
|
|
//! into durable test output, and the minted token is only ever passed to
|
|
//! `git` through the child process environment.
|
|
|
|
use std::collections::BTreeSet;
|
|
use std::process::Stdio;
|
|
use std::time::Duration;
|
|
|
|
use fabro_github::token_source::InstallationTokenSource;
|
|
use fabro_github::{GitHubAppCredentials, GitHubCredentials, GitHubRepositoryAccess};
|
|
use fabro_types::GitHubRepositorySlug;
|
|
use tokio::process::Command;
|
|
use tokio::time::sleep;
|
|
|
|
fn env_var(name: &str) -> String {
|
|
#[expect(
|
|
clippy::disallowed_methods,
|
|
reason = "live e2e configuration comes from the process environment by design"
|
|
)]
|
|
std::env::var(name).unwrap_or_else(|_| panic!("{name} must be set for this live test"))
|
|
}
|
|
|
|
async fn ls_remote_with_token(slug: &GitHubRepositorySlug, token: &str) -> bool {
|
|
let url = slug.https_url();
|
|
let mut command = Command::new("git");
|
|
fabro_github::apply_probe_git_env(&mut command, token);
|
|
let output = command
|
|
.args(["ls-remote", &url, "HEAD"])
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null())
|
|
.output()
|
|
.await
|
|
.expect("git should run");
|
|
output.status.success()
|
|
}
|
|
|
|
#[fabro_macros::e2e_test(
|
|
live("FABRO_TEST_GITHUB_APP_ID"),
|
|
live("GITHUB_APP_PRIVATE_KEY"),
|
|
live("FABRO_TEST_GITHUB_ORIGIN"),
|
|
live("FABRO_TEST_GITHUB_ADDITIONAL_REPO")
|
|
)]
|
|
async fn scoped_token_reaches_the_declared_additional_repository() {
|
|
let app_id = env_var("FABRO_TEST_GITHUB_APP_ID");
|
|
let app = GitHubAppCredentials::from_env(Some(&app_id))
|
|
.expect("GITHUB_APP_PRIVATE_KEY should decode as PEM or base64 PEM")
|
|
.expect("GITHUB_APP_PRIVATE_KEY must be set for this live test");
|
|
let origin = env_var("FABRO_TEST_GITHUB_ORIGIN");
|
|
let additional: GitHubRepositorySlug = env_var("FABRO_TEST_GITHUB_ADDITIONAL_REPO")
|
|
.parse()
|
|
.expect("FABRO_TEST_GITHUB_ADDITIONAL_REPO must be an owner/repository slug");
|
|
let additional_set: BTreeSet<GitHubRepositorySlug> = [additional.clone()].into_iter().collect();
|
|
|
|
let access = GitHubRepositoryAccess::new(
|
|
Some(&origin),
|
|
&additional_set,
|
|
std::collections::HashMap::from([("contents".to_string(), "read".to_string())]),
|
|
)
|
|
.expect("access request should validate")
|
|
.expect("origin should produce an access value");
|
|
|
|
// The production choreography: every target resolves to one shared
|
|
// installation, then one mint scoped to the whole effective set.
|
|
let creds = GitHubCredentials::App(app.clone());
|
|
let source =
|
|
InstallationTokenSource::for_access(&creds, &access).expect("token source should build");
|
|
let resolved = source.resolve().await.expect("scoped mint should succeed");
|
|
let token = resolved.token.expose();
|
|
|
|
// The one token reads both the primary and the additional repository.
|
|
// A freshly minted token can hit GitHub's replication lag, so retry a
|
|
// few times with the same token before failing.
|
|
for slug in access.targets() {
|
|
let mut reachable = false;
|
|
for _ in 0..3 {
|
|
if ls_remote_with_token(slug, token).await {
|
|
reachable = true;
|
|
break;
|
|
}
|
|
sleep(Duration::from_secs(2)).await;
|
|
}
|
|
assert!(
|
|
reachable,
|
|
"scoped token should read every declared repository"
|
|
);
|
|
}
|
|
|
|
// Negative scope check: a token minted for the primary alone must not
|
|
// read the additional repository (proves server-side scoping, not just
|
|
// possession of a token).
|
|
let primary_only = GitHubRepositoryAccess::new(
|
|
Some(&origin),
|
|
&BTreeSet::new(),
|
|
std::collections::HashMap::from([("contents".to_string(), "read".to_string())]),
|
|
)
|
|
.expect("primary-only access should validate")
|
|
.expect("origin should produce an access value");
|
|
let narrow_source =
|
|
InstallationTokenSource::for_access(&GitHubCredentials::App(app), &primary_only)
|
|
.expect("primary-only token source should build");
|
|
let narrow = narrow_source
|
|
.resolve()
|
|
.await
|
|
.expect("primary-only mint should succeed");
|
|
assert!(
|
|
!ls_remote_with_token(&additional, narrow.token.expose()).await,
|
|
"a primary-only token must not read the additional repository"
|
|
);
|
|
}
|