//! Git browser route tests: repo overview, tree, file, commits, raw, 404s. //! //! Creates temp bare repos with git2 to test the actual HTTP routes. use crate::harness::TestHarness; /// Create a temp bare repo at `{dir}/testowner/testrepo.git` with two commits on "main". /// Commit 1 (root): README.md, src/main.rs /// Commit 2: modifies src/main.rs (adds a line) fn make_test_repo(dir: &std::path::Path) { let bare_path = dir.join("testowner").join("testrepo.git"); std::fs::create_dir_all(&bare_path).unwrap(); let bare_repo = git2::Repository::init_bare(&bare_path).unwrap(); let sig = git2::Signature::now("Test", "test@example.com").unwrap(); // Create blobs let readme_oid = bare_repo.blob(b"# Test Repo\n\nHello world.").unwrap(); let main_rs_oid = bare_repo .blob(b"fn main() {\n println!(\"hello\");\n}\n") .unwrap(); // Build src/ subtree let mut src_tb = bare_repo.treebuilder(None).unwrap(); src_tb.insert("main.rs", main_rs_oid, 0o100644).unwrap(); let src_tree_oid = src_tb.write().unwrap(); // Build root tree let mut root_tb = bare_repo.treebuilder(None).unwrap(); root_tb.insert("README.md", readme_oid, 0o100644).unwrap(); root_tb.insert("src", src_tree_oid, 0o040000).unwrap(); let root_tree_oid = root_tb.write().unwrap(); let root_tree = bare_repo.find_tree(root_tree_oid).unwrap(); let first_commit_oid = bare_repo .commit( Some("refs/heads/main"), &sig, &sig, "Initial commit", &root_tree, &[], ) .unwrap(); bare_repo.set_head("refs/heads/main").unwrap(); // Second commit: modify src/main.rs let first_commit = bare_repo.find_commit(first_commit_oid).unwrap(); let main_rs_oid_v2 = bare_repo .blob(b"fn main() {\n println!(\"hello\");\n println!(\"world\");\n}\n") .unwrap(); let mut src_tb2 = bare_repo.treebuilder(None).unwrap(); src_tb2.insert("main.rs", main_rs_oid_v2, 0o100644).unwrap(); let src_tree_oid2 = src_tb2.write().unwrap(); let mut root_tb2 = bare_repo.treebuilder(None).unwrap(); root_tb2.insert("README.md", readme_oid, 0o100644).unwrap(); root_tb2.insert("src", src_tree_oid2, 0o040000).unwrap(); let root_tree_oid2 = root_tb2.write().unwrap(); let root_tree2 = bare_repo.find_tree(root_tree_oid2).unwrap(); bare_repo .commit( Some("refs/heads/main"), &sig, &sig, "Add world output", &root_tree2, &[&first_commit], ) .unwrap(); } /// Set up a harness with git repos and a user matching the disk owner. async fn setup_git_harness(tmp: &tempfile::TempDir) -> TestHarness { let mut h = TestHarness::with_git_repos(tmp.path().to_str().unwrap().to_string()).await; // Create a user whose username matches the disk directory h.signup("testowner", "testowner@example.com", "password123").await; h } // ── 404 when git not configured ── #[tokio::test] async fn git_repo_returns_404_when_not_configured() { let mut h = TestHarness::new().await; let resp = h.client.get("/git/owner/repo").await; assert_eq!(resp.status, 404, "No git_repos_path → 404"); } // ── Repo overview ── #[tokio::test] async fn git_repo_overview() { let tmp = tempfile::TempDir::new().unwrap(); make_test_repo(tmp.path()); let mut h = setup_git_harness(&tmp).await; let resp = h.client.get("/git/testowner/testrepo").await; assert!( resp.status.is_success(), "Repo overview failed: {} {}", resp.status, resp.text ); // HTML should contain the repo name and README content assert!(resp.text.contains("testrepo"), "Should show repo name"); assert!(resp.text.contains("Test Repo"), "Should render README"); } // ── Nonexistent repo ── #[tokio::test] async fn git_nonexistent_repo_returns_404() { let tmp = tempfile::TempDir::new().unwrap(); make_test_repo(tmp.path()); let mut h = setup_git_harness(&tmp).await; let resp = h.client.get("/git/testowner/nope").await; assert_eq!(resp.status, 404); } // ── Tree at ref ── #[tokio::test] async fn git_tree_at_ref() { let tmp = tempfile::TempDir::new().unwrap(); make_test_repo(tmp.path()); let mut h = setup_git_harness(&tmp).await; let resp = h.client.get("/git/testowner/testrepo/tree/main").await; assert!( resp.status.is_success(), "Tree at ref failed: {} {}", resp.status, resp.text ); // Should list files: README.md and src/ assert!(resp.text.contains("README.md"), "Should show README.md"); assert!(resp.text.contains("src"), "Should show src directory"); } // ── Subdirectory ── #[tokio::test] async fn git_tree_subdirectory() { let tmp = tempfile::TempDir::new().unwrap(); make_test_repo(tmp.path()); let mut h = setup_git_harness(&tmp).await; let resp = h .client .get("/git/testowner/testrepo/tree/main/src") .await; assert!( resp.status.is_success(), "Subdirectory failed: {} {}", resp.status, resp.text ); assert!(resp.text.contains("main.rs"), "Should show main.rs in src/"); } // ── File view ── #[tokio::test] async fn git_file_view() { let tmp = tempfile::TempDir::new().unwrap(); make_test_repo(tmp.path()); let mut h = setup_git_harness(&tmp).await; let resp = h .client .get("/git/testowner/testrepo/tree/main/src/main.rs") .await; assert!( resp.status.is_success(), "File view failed: {} {}", resp.status, resp.text ); assert!( resp.text.contains("println!"), "Should show file content with println!" ); } #[tokio::test] async fn git_file_nonexistent_returns_404() { let tmp = tempfile::TempDir::new().unwrap(); make_test_repo(tmp.path()); let mut h = setup_git_harness(&tmp).await; let resp = h .client .get("/git/testowner/testrepo/tree/main/nope.txt") .await; assert_eq!(resp.status, 404); } // ── Commit log ── #[tokio::test] async fn git_commit_log() { let tmp = tempfile::TempDir::new().unwrap(); make_test_repo(tmp.path()); let mut h = setup_git_harness(&tmp).await; let resp = h .client .get("/git/testowner/testrepo/commits/main") .await; assert!( resp.status.is_success(), "Commit log failed: {} {}", resp.status, resp.text ); assert!( resp.text.contains("Initial commit"), "Should show commit message" ); assert!( resp.text.contains("Add world output"), "Should show second commit message" ); } // ── Raw file ── #[tokio::test] async fn git_raw_file() { let tmp = tempfile::TempDir::new().unwrap(); make_test_repo(tmp.path()); let mut h = setup_git_harness(&tmp).await; let resp = h .client .get("/git/testowner/testrepo/raw/main/README.md") .await; assert!( resp.status.is_success(), "Raw file failed: {} {}", resp.status, resp.text ); assert!( resp.text.contains("# Test Repo"), "Should return raw file content" ); } // ── Path traversal ── #[tokio::test] async fn git_path_traversal_rejected() { let tmp = tempfile::TempDir::new().unwrap(); make_test_repo(tmp.path()); let mut h = setup_git_harness(&tmp).await; let resp = h.client.get("/git/../etc/testrepo").await; // Axum may normalize or reject; we just check it doesn't succeed assert!( resp.status.is_client_error() || resp.status.is_server_error() || resp.status == 404, "Traversal should not succeed: {}", resp.status ); } // ── Invalid ref ── #[tokio::test] async fn git_invalid_ref_returns_404() { let tmp = tempfile::TempDir::new().unwrap(); make_test_repo(tmp.path()); let mut h = setup_git_harness(&tmp).await; let resp = h .client .get("/git/testowner/testrepo/tree/nonexistent-branch") .await; assert_eq!(resp.status, 404); } // ── Visibility: private repo ── #[tokio::test] async fn git_private_repo_hidden_from_anonymous() { let tmp = tempfile::TempDir::new().unwrap(); make_test_repo(tmp.path()); let mut h = setup_git_harness(&tmp).await; // Visit the repo to auto-register it let resp = h.client.get("/git/testowner/testrepo").await; assert!(resp.status.is_success()); // Set visibility to private via SQL sqlx::query("UPDATE git_repos SET visibility = 'private' WHERE name = 'testrepo'") .execute(&h.db) .await .unwrap(); // Log out so we're anonymous h.client.post_form("/logout", "").await; let resp = h.client.get("/git/testowner/testrepo").await; assert_eq!(resp.status, 404, "Private repo should be 404 for anonymous users"); } #[tokio::test] async fn git_private_repo_visible_to_owner() { let tmp = tempfile::TempDir::new().unwrap(); make_test_repo(tmp.path()); let mut h = setup_git_harness(&tmp).await; // Visit the repo to auto-register it let resp = h.client.get("/git/testowner/testrepo").await; assert!(resp.status.is_success()); // Set visibility to private sqlx::query("UPDATE git_repos SET visibility = 'private' WHERE name = 'testrepo'") .execute(&h.db) .await .unwrap(); // Log in as the owner h.login("testowner", "password123").await; let resp = h.client.get("/git/testowner/testrepo").await; assert!(resp.status.is_success(), "Owner should see private repo: {} {}", resp.status, resp.text); } // ── Commit detail ── #[tokio::test] async fn git_commit_detail_page() { let tmp = tempfile::TempDir::new().unwrap(); make_test_repo(tmp.path()); let mut h = setup_git_harness(&tmp).await; // Get the HEAD commit OID from the commit log page let log_resp = h.client.get("/git/testowner/testrepo/commits/main").await; assert!(log_resp.status.is_success()); // Extract a commit OID from the page (look for /commit/ link) let oid = log_resp.text .split("/git/testowner/testrepo/commit/") .nth(1) .and_then(|s| s.split('"').next()) .expect("Should find commit OID link in commit log"); let resp = h.client.get(&format!("/git/testowner/testrepo/commit/{oid}")).await; assert!(resp.status.is_success(), "Commit detail failed: {} {}", resp.status, resp.text); assert!(resp.text.contains("file"), "Should show diff stats"); } #[tokio::test] async fn git_commit_detail_root_commit() { let tmp = tempfile::TempDir::new().unwrap(); make_test_repo(tmp.path()); let mut h = setup_git_harness(&tmp).await; // Get the root commit — it's the oldest one. Fetch commit log page 1. let log_resp = h.client.get("/git/testowner/testrepo/commits/main").await; assert!(log_resp.status.is_success()); // The root commit's message is "Initial commit" // Find its OID link let text = &log_resp.text; let initial_idx = text.find("Initial commit").expect("Should find Initial commit"); // The OID link is nearby — search after the message for /commit/ link let after_initial = &text[initial_idx..]; let oid = after_initial .split("/git/testowner/testrepo/commit/") .nth(1) .and_then(|s| s.split('"').next()) .expect("Should find commit OID for root commit"); let resp = h.client.get(&format!("/git/testowner/testrepo/commit/{oid}")).await; assert!(resp.status.is_success(), "Root commit detail failed: {} {}", resp.status, resp.text); assert!(resp.text.contains("Initial commit"), "Should show root commit message"); // Root commit should have additions (all files are new) assert!(resp.text.contains("insertion"), "Root commit should show insertions"); } #[tokio::test] async fn git_commit_detail_nonexistent_404() { let tmp = tempfile::TempDir::new().unwrap(); make_test_repo(tmp.path()); let mut h = setup_git_harness(&tmp).await; let resp = h.client.get("/git/testowner/testrepo/commit/0000000000000000000000000000000000000000").await; assert_eq!(resp.status, 404); } #[tokio::test] async fn git_commit_detail_invalid_oid_404() { let tmp = tempfile::TempDir::new().unwrap(); make_test_repo(tmp.path()); let mut h = setup_git_harness(&tmp).await; let resp = h.client.get("/git/testowner/testrepo/commit/not-a-valid-oid").await; assert_eq!(resp.status, 404); } // ── Blame view ── #[tokio::test] async fn git_blame_view() { let tmp = tempfile::TempDir::new().unwrap(); make_test_repo(tmp.path()); let mut h = setup_git_harness(&tmp).await; let resp = h.client.get("/git/testowner/testrepo/blame/main/src/main.rs").await; assert!(resp.status.is_success(), "Blame view failed: {} {}", resp.status, resp.text); assert!(resp.text.contains("println!"), "Blame should show file content"); // Should have commit short OIDs in the blame gutter assert!(resp.text.contains("/commit/"), "Blame should link to commits"); } #[tokio::test] async fn git_blame_nonexistent_file_404() { let tmp = tempfile::TempDir::new().unwrap(); make_test_repo(tmp.path()); let mut h = setup_git_harness(&tmp).await; let resp = h.client.get("/git/testowner/testrepo/blame/main/nope.txt").await; assert_eq!(resp.status, 404); } // ── User repos listing ── #[tokio::test] async fn git_user_repos_listing() { let tmp = tempfile::TempDir::new().unwrap(); make_test_repo(tmp.path()); let mut h = setup_git_harness(&tmp).await; // Visit repo to auto-register it let resp = h.client.get("/git/testowner/testrepo").await; assert!(resp.status.is_success()); let resp = h.client.get("/git/testowner").await; assert!(resp.status.is_success(), "User repos listing failed: {} {}", resp.status, resp.text); assert!(resp.text.contains("testrepo"), "Should list the repo"); } #[tokio::test] async fn git_user_repos_nonexistent_user_404() { let tmp = tempfile::TempDir::new().unwrap(); make_test_repo(tmp.path()); let mut h = setup_git_harness(&tmp).await; let resp = h.client.get("/git/nobody").await; assert_eq!(resp.status, 404); } // ── Git explore (landing) ── #[tokio::test] async fn git_landing_shows_explore_logged_in() { let tmp = tempfile::TempDir::new().unwrap(); make_test_repo(tmp.path()); let mut h = setup_git_harness(&tmp).await; h.login("testowner", "password123").await; // Visit repo to auto-register it let resp = h.client.get("/git/testowner/testrepo").await; assert!(resp.status.is_success()); let resp = h.client.get("/git").await; assert_eq!(resp.status, 200, "Explore page should return 200 for logged-in users"); assert!(resp.text.contains("Repositories"), "Should show Repositories heading"); } #[tokio::test] async fn git_landing_shows_explore_anonymous() { let tmp = tempfile::TempDir::new().unwrap(); make_test_repo(tmp.path()); let mut h = setup_git_harness(&tmp).await; // Visit repo to auto-register it let resp = h.client.get("/git/testowner/testrepo").await; assert!(resp.status.is_success()); // Logout h.client.post_form("/logout", "").await; let resp = h.client.get("/git").await; assert_eq!(resp.status, 200, "Explore page should return 200 for anonymous users"); assert!(resp.text.contains("Repositories"), "Should show Repositories heading"); } #[tokio::test] async fn git_explore_page_shows_public_repos() { let tmp = tempfile::TempDir::new().unwrap(); make_test_repo(tmp.path()); let mut h = setup_git_harness(&tmp).await; // Visit repo to auto-register it (default visibility is public) let resp = h.client.get("/git/testowner/testrepo").await; assert!(resp.status.is_success()); let resp = h.client.get("/git").await; assert_eq!(resp.status, 200); assert!(resp.text.contains("testowner"), "Should show owner name"); assert!(resp.text.contains("testrepo"), "Should show repo name"); } #[tokio::test] async fn git_explore_page_hides_private_repos() { let tmp = tempfile::TempDir::new().unwrap(); make_test_repo(tmp.path()); let mut h = setup_git_harness(&tmp).await; // Visit repo to auto-register it let resp = h.client.get("/git/testowner/testrepo").await; assert!(resp.status.is_success()); // Set visibility to private sqlx::query("UPDATE git_repos SET visibility = 'private' WHERE name = 'testrepo'") .execute(&h.db) .await .unwrap(); // Logout and check explore page h.client.post_form("/logout", "").await; let resp = h.client.get("/git").await; assert_eq!(resp.status, 200); assert!(!resp.text.contains("testrepo"), "Private repo should not appear on explore page"); } // ── File history ── #[tokio::test] async fn git_file_history() { let tmp = tempfile::TempDir::new().unwrap(); make_test_repo(tmp.path()); let mut h = setup_git_harness(&tmp).await; let resp = h.client.get("/git/testowner/testrepo/log/main/src/main.rs").await; assert!(resp.status.is_success(), "File history failed: {} {}", resp.status, resp.text); // Both commits touched src/main.rs assert!(resp.text.contains("Initial commit"), "Should show initial commit"); assert!(resp.text.contains("Add world output"), "Should show second commit"); } #[tokio::test] async fn git_file_history_filters_unrelated() { let tmp = tempfile::TempDir::new().unwrap(); make_test_repo(tmp.path()); let mut h = setup_git_harness(&tmp).await; let resp = h.client.get("/git/testowner/testrepo/log/main/README.md").await; assert!(resp.status.is_success(), "File history failed: {} {}", resp.status, resp.text); // README.md was only added in the initial commit, not changed in the second assert!(resp.text.contains("Initial commit"), "Should show initial commit for README.md"); assert!(!resp.text.contains("Add world output"), "Should not show unrelated commit"); } #[tokio::test] async fn git_file_history_nonexistent_file() { let tmp = tempfile::TempDir::new().unwrap(); make_test_repo(tmp.path()); let mut h = setup_git_harness(&tmp).await; let resp = h.client.get("/git/testowner/testrepo/log/main/nope.txt").await; assert!(resp.status.is_success(), "Nonexistent file history should render empty, not 404: {} {}", resp.status, resp.text); assert!(resp.text.contains("No commits found"), "Should show empty message"); } #[tokio::test] async fn git_file_view_has_history_link() { let tmp = tempfile::TempDir::new().unwrap(); make_test_repo(tmp.path()); let mut h = setup_git_harness(&tmp).await; let resp = h.client.get("/git/testowner/testrepo/tree/main/src/main.rs").await; assert!(resp.status.is_success()); assert!(resp.text.contains("/log/main/src/main.rs"), "Should have history link"); } // ── File view line linking ── #[tokio::test] async fn git_file_view_has_line_links() { let tmp = tempfile::TempDir::new().unwrap(); make_test_repo(tmp.path()); let mut h = setup_git_harness(&tmp).await; let resp = h.client.get("/git/testowner/testrepo/tree/main/src/main.rs").await; assert!(resp.status.is_success()); assert!(resp.text.contains("href=\"#L1\""), "Should have line link anchors"); assert!(resp.text.contains("id=\"L1\""), "Should have line anchor IDs"); } // ── File view has blame link ── #[tokio::test] async fn git_file_view_has_blame_link() { let tmp = tempfile::TempDir::new().unwrap(); make_test_repo(tmp.path()); let mut h = setup_git_harness(&tmp).await; let resp = h.client.get("/git/testowner/testrepo/tree/main/src/main.rs").await; assert!(resp.status.is_success()); assert!(resp.text.contains("/blame/main/src/main.rs"), "Should have blame link"); } // ── Nav bar consistency ── #[tokio::test] async fn git_nav_bar_present_on_all_pages() { let tmp = tempfile::TempDir::new().unwrap(); make_test_repo(tmp.path()); let mut h = setup_git_harness(&tmp).await; // Repo overview let resp = h.client.get("/git/testowner/testrepo").await; assert!(resp.text.contains("git-nav-links"), "Repo overview should have nav"); // Tree let resp = h.client.get("/git/testowner/testrepo/tree/main").await; assert!(resp.text.contains("git-nav-links"), "Tree should have nav"); // Subdirectory let resp = h.client.get("/git/testowner/testrepo/tree/main/src").await; assert!(resp.text.contains("git-nav-links"), "Subdirectory should have nav"); // File view let resp = h.client.get("/git/testowner/testrepo/tree/main/src/main.rs").await; assert!(resp.text.contains("git-nav-links"), "File view should have nav"); // Commits let resp = h.client.get("/git/testowner/testrepo/commits/main").await; assert!(resp.text.contains("git-nav-links"), "Commits should have nav"); } // ── No emoji in tree views ── #[tokio::test] async fn git_tree_no_emoji() { let tmp = tempfile::TempDir::new().unwrap(); make_test_repo(tmp.path()); let mut h = setup_git_harness(&tmp).await; let resp = h.client.get("/git/testowner/testrepo").await; assert!(!resp.text.contains("\u{1F4C1}"), "Repo overview should not have folder emoji"); assert!(!resp.text.contains("\u{1F4C4}"), "Repo overview should not have file emoji"); assert!(!resp.text.contains("📁"), "No folder emoji HTML entity"); assert!(!resp.text.contains("📄"), "No file emoji HTML entity"); let resp = h.client.get("/git/testowner/testrepo/tree/main/src").await; assert!(!resp.text.contains("\u{1F4C1}"), "Subdirectory should not have folder emoji"); assert!(!resp.text.contains("\u{1F4C4}"), "Subdirectory should not have file emoji"); }