diff --git a/signal-gateway-app-code/Cargo.toml b/signal-gateway-app-code/Cargo.toml index bd778cd..50dfd16 100644 --- a/signal-gateway-app-code/Cargo.toml +++ b/signal-gateway-app-code/Cargo.toml @@ -18,3 +18,6 @@ serde_json = { workspace = true } tar = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["rt-multi-thread"] } diff --git a/signal-gateway-app-code/src/lib.rs b/signal-gateway-app-code/src/lib.rs index 07a446e..303e7b7 100644 --- a/signal-gateway-app-code/src/lib.rs +++ b/signal-gateway-app-code/src/lib.rs @@ -60,7 +60,8 @@ pub struct AppCodeConfig { /// GitHub repository in "owner/repo" format. pub github: GitHubRepo, /// Path to file containing the GitHub personal access token. - pub token_file: PathBuf, + /// Optional for public repositories (unauthenticated access has lower rate limits). + pub token_file: Option, } /// A file stored in memory from the tarball. @@ -92,7 +93,7 @@ pub type ShaCallback = Arc< /// Downloads and caches GitHub tarballs for browsing application source code. pub struct AppCode { config: AppCodeConfig, - token: String, + token: Option, get_sha: ShaCallback, client: reqwest::Client, cache: Mutex>, @@ -104,9 +105,11 @@ impl AppCode { /// The `get_sha` callback is called to determine which git SHA to download. /// It should return `None` if the SHA is not yet known. pub fn new(config: AppCodeConfig, get_sha: ShaCallback) -> Result { - let token = std::fs::read_to_string(&config.token_file)? - .trim() - .to_string(); + let token = config + .token_file + .as_ref() + .map(|path| std::fs::read_to_string(path).map(|s| s.trim().to_string())) + .transpose()?; Ok(Self { config, @@ -188,13 +191,18 @@ impl AppCode { self.config.github.owner, self.config.github.repo, sha ); - let response = self + let mut request = self .client .get(&url) - .header("Authorization", format!("Bearer {}", self.token)) .header("Accept", "application/vnd.github+json") .header("User-Agent", "signal-gateway") - .header("X-GitHub-Api-Version", "2022-11-28") + .header("X-GitHub-Api-Version", "2022-11-28"); + + if let Some(token) = &self.token { + request = request.header("Authorization", format!("Bearer {}", token)); + } + + let response = request .send() .await .map_err(|e| format!("HTTP request failed: {e}"))?; diff --git a/signal-gateway-app-code/tests/integration.rs b/signal-gateway-app-code/tests/integration.rs new file mode 100644 index 0000000..f781c03 --- /dev/null +++ b/signal-gateway-app-code/tests/integration.rs @@ -0,0 +1,175 @@ +//! Integration tests for signal-gateway-app-code. +//! +//! These tests exercise the GitHub tarball download and file browsing functionality +//! against a real public repository at a pinned commit. + +use signal_gateway_app_code::{AppCode, AppCodeConfig, GitHubRepo, ShaCallback}; +use std::sync::Arc; + +/// Test against cbeck88/ver-stub-rs at a known commit. +/// This is a public repo, so no token is needed. +const TEST_OWNER: &str = "cbeck88"; +const TEST_REPO: &str = "ver-stub-rs"; +const TEST_SHA: &str = "79b98e25f27ae4f5dd73a5a3d8f37dad655a57e8"; + +fn create_test_app_code() -> AppCode { + let config = AppCodeConfig { + name: "test-app".to_string(), + github: GitHubRepo { + owner: TEST_OWNER.to_string(), + repo: TEST_REPO.to_string(), + }, + token_file: None, // Public repo, no auth needed + }; + + let sha = TEST_SHA.to_string(); + let sha_callback: ShaCallback = Arc::new(move || { + let sha = sha.clone(); + Box::pin(async move { Ok(sha) }) + }); + + AppCode::new(config, sha_callback).expect("Failed to create AppCode") +} + +#[tokio::test] +async fn test_ls_root() { + let app = create_test_app_code(); + + let result = app.ls(None).await.expect("ls failed"); + + // Verify expected top-level entries exist + assert!(result.contains("Cargo.toml"), "Should contain Cargo.toml"); + assert!(result.contains("README.md"), "Should contain README.md"); + assert!(result.contains("ver-stub/"), "Should contain ver-stub/ directory"); + assert!(result.contains("ver-stub-build/"), "Should contain ver-stub-build/ directory"); + assert!(result.contains("tests.sh"), "Should contain tests.sh"); +} + +#[tokio::test] +async fn test_ls_subdirectory() { + let app = create_test_app_code(); + + let result = app.ls(Some("ver-stub")).await.expect("ls failed"); + + // Should have src/ directory and Cargo.toml + assert!(result.contains("src/"), "Should contain src/ directory"); + assert!(result.contains("Cargo.toml"), "Should contain Cargo.toml"); +} + +#[tokio::test] +async fn test_find_rust_files() { + let app = create_test_app_code(); + + let result = app.find(Some("*.rs")).await.expect("find failed"); + + // Should find Rust source files + assert!(result.contains("ver-stub/src/lib.rs"), "Should find ver-stub/src/lib.rs"); + assert!(result.contains("ver-stub-build/src/lib.rs"), "Should find ver-stub-build/src/lib.rs"); +} + +#[tokio::test] +async fn test_find_with_path_pattern() { + let app = create_test_app_code(); + + let result = app.find(Some("ver-stub-build/src/*.rs")).await.expect("find failed"); + + // Should find files in ver-stub-build/src/ + assert!(result.contains("ver-stub-build/src/lib.rs"), "Should find lib.rs"); +} + +#[tokio::test] +async fn test_read_cargo_toml() { + let app = create_test_app_code(); + + let result = app.read("Cargo.toml", None, None).await.expect("read failed"); + + // Verify content matches what we know is in the file + assert!(result.contains("[workspace]"), "Should contain [workspace]"); + assert!(result.contains("ver-stub"), "Should contain ver-stub"); + assert!(result.contains("ver-stub-build"), "Should contain ver-stub-build"); +} + +#[tokio::test] +async fn test_read_with_line_range() { + let app = create_test_app_code(); + + // Read just the first 5 lines + let result = app.read("Cargo.toml", Some(1), Some(5)).await.expect("read failed"); + + // Should only have 5 lines + let line_count = result.lines().count(); + assert_eq!(line_count, 5, "Should have exactly 5 lines"); + + // First line should be [workspace] + assert!(result.contains("[workspace]"), "First lines should contain [workspace]"); +} + +#[tokio::test] +async fn test_read_nonexistent_file() { + let app = create_test_app_code(); + + let result = app.read("nonexistent-file.txt", None, None).await; + + assert!(result.is_err(), "Should fail for nonexistent file"); + assert!( + result.unwrap_err().contains("not found"), + "Error should mention file not found" + ); +} + +#[tokio::test] +async fn test_search_simple() { + let app = create_test_app_code(); + + let result = app.search("workspace", 0, None).await.expect("search failed"); + + // Should find "workspace" in Cargo.toml + assert!(result.contains("Cargo.toml"), "Should find match in Cargo.toml"); +} + +#[tokio::test] +async fn test_search_with_context() { + let app = create_test_app_code(); + + let result = app.search("resolver", 2, None).await.expect("search failed"); + + // Should have context lines around the match + assert!(result.contains("Cargo.toml"), "Should find match in Cargo.toml"); + // With context, should see surrounding lines + assert!(result.contains("[workspace]"), "Should show context including [workspace]"); +} + +#[tokio::test] +async fn test_search_with_path_prefix() { + let app = create_test_app_code(); + + // Search only in ver-stub-build directory + let result = app + .search("pub", 0, Some("ver-stub-build/src")) + .await + .expect("search failed"); + + // Should only find matches in ver-stub-build/src + for line in result.lines() { + if line.contains(":") && !line.starts_with('[') { + // This is a match line (path:line: content) + assert!( + line.starts_with("ver-stub-build/src"), + "All matches should be in ver-stub-build/src, got: {}", + line + ); + } + } +} + +#[tokio::test] +async fn test_search_no_matches() { + let app = create_test_app_code(); + + let result = app + .search("xyzzy_unlikely_string_12345", 0, None) + .await + .expect("search failed"); + + assert!(result.contains("No matches"), "Should report no matches"); +}