//! 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, 0o100_644).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, 0o100_644).unwrap(); root_tb.insert("src", src_tree_oid, 0o040_000).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, 0o100_644) .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, 0o100_644).unwrap(); root_tb2.insert("src", src_tree_oid2, 0o040_000).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()); 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 ); } #[tokio::test] async fn git_private_repo_visible_to_read_collaborator() { // Run #21 authz reconciliation: a read-collaborator (who can already clone // over SSH) must be able to read a private repo over HTTP too, previously // HTTP was owner-only and 404'd them. 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; // auto-register assert!(resp.status.is_success()); sqlx::query("UPDATE git_repos SET visibility = 'private' WHERE name = 'testrepo'") .execute(&h.db) .await .unwrap(); // A logged-in non-collaborator is still denied. let outsider = h .signup("outsider", "outsider@example.com", "password123") .await; h.login("outsider", "password123").await; let resp = h.client.get("/git/testowner/testrepo").await; assert_eq!( resp.status, 404, "non-collaborator must not see a private repo" ); // Grant read access, then they can see it. let repo_id: uuid::Uuid = sqlx::query_scalar("SELECT id FROM git_repos WHERE name = 'testrepo'") .fetch_one(&h.db) .await .unwrap(); sqlx::query( "INSERT INTO repo_collaborators (repo_id, user_id, can_push) VALUES ($1, $2, false)", ) .bind(repo_id) .bind(outsider) .execute(&h.db) .await .unwrap(); let resp = h.client.get("/git/testowner/testrepo").await; assert!( resp.status.is_success(), "read-collaborator should see private repo over HTTP: {} {}", 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()); 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()); 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" ); 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" ); 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" ); } // ── Personal access tokens (git over HTTPS) ── fn basic_auth(token: &str) -> String { use base64::Engine; // git puts the token in the password field; username is ignored. let creds = base64::engine::general_purpose::STANDARD.encode(format!("x:{token}")); format!("Basic {creds}") } #[tokio::test] async fn git_token_clones_private_repo_and_revokes() { let tmp = tempfile::TempDir::new().unwrap(); make_test_repo(tmp.path()); let mut h = setup_git_harness(&tmp).await; // signs up + logs in testowner h.client.get("/git/testowner/testrepo").await; // auto-register sqlx::query("UPDATE git_repos SET visibility = 'private' WHERE name = 'testrepo'") .execute(&h.db) .await .unwrap(); // Owner mints a read-only token; the create response body is the plaintext. h.login("testowner", "password123").await; h.client.fetch_csrf_token().await; let resp = h .client .post_form("/api/users/me/git-tokens", "name=laptop") .await; assert!( resp.status.is_success(), "create token: {} {}", resp.status, resp.text ); let token = resp.text.trim().to_string(); assert!( token.starts_with("mnw_"), "expected mnw_-prefixed token, got: {token}" ); // Simulate a CLI clone: no session cookie, token via Basic auth. h.client.clear_cookies(); let info_refs = "/git/testowner/testrepo.git/info/refs?service=git-upload-pack"; // No credentials → private repo is invisible. let resp = h.client.get(info_refs).await; assert_eq!(resp.status, 404, "anonymous must not reach a private repo"); // Valid token → clone advertisement succeeds. let resp = h .client .request_with_headers( "GET", info_refs, None, &[("Authorization", &basic_auth(&token))], ) .await; assert!( resp.status.is_success(), "token clone failed: {} {}", resp.status, resp.text ); assert!( resp.text.contains("refs/heads/main"), "advertisement missing refs: {}", resp.text ); // Garbage token → still 404. let resp = h .client .request_with_headers( "GET", info_refs, None, &[("Authorization", &basic_auth("mnw_bogus"))], ) .await; assert_eq!(resp.status, 404, "bad token must not authorize"); // Read-only token cannot push: receive-pack advertisement is forbidden. let recv = "/git/testowner/testrepo.git/info/refs?service=git-receive-pack"; let resp = h .client .request_with_headers("GET", recv, None, &[("Authorization", &basic_auth(&token))]) .await; assert_eq!( resp.status, 403, "read-only token must not get a push advertisement: {}", resp.status ); // Revoke the token (re-auth as owner first). let token_id: uuid::Uuid = sqlx::query_scalar("SELECT id FROM git_access_tokens LIMIT 1") .fetch_one(&h.db) .await .unwrap(); h.login("testowner", "password123").await; h.client.fetch_csrf_token().await; let resp = h .client .delete(&format!("/api/users/me/git-tokens/{token_id}")) .await; assert!( resp.status.is_success() || resp.status == 204, "revoke: {} {}", resp.status, resp.text ); // Revoked token no longer authorizes. h.client.clear_cookies(); let resp = h .client .request_with_headers( "GET", info_refs, None, &[("Authorization", &basic_auth(&token))], ) .await; assert_eq!(resp.status, 404, "revoked token must stop working"); } #[tokio::test] async fn git_push_token_gets_receive_pack_advertisement() { let tmp = tempfile::TempDir::new().unwrap(); make_test_repo(tmp.path()); let mut h = setup_git_harness(&tmp).await; h.client.get("/git/testowner/testrepo").await; sqlx::query("UPDATE git_repos SET visibility = 'private' WHERE name = 'testrepo'") .execute(&h.db) .await .unwrap(); h.login("testowner", "password123").await; h.client.fetch_csrf_token().await; let resp = h .client .post_form("/api/users/me/git-tokens", "name=ci&can_push=on") .await; assert!( resp.status.is_success(), "create push token: {} {}", resp.status, resp.text ); let token = resp.text.trim().to_string(); h.client.clear_cookies(); let recv = "/git/testowner/testrepo.git/info/refs?service=git-receive-pack"; let resp = h .client .request_with_headers("GET", recv, None, &[("Authorization", &basic_auth(&token))]) .await; assert!( resp.status.is_success(), "push token should get receive-pack advertisement: {} {}", resp.status, resp.text ); assert!( resp.text.contains("# service=git-receive-pack"), "missing receive-pack banner: {}", resp.text ); } // UX-S1: git push (receive-pack) must reject session-cookie auth, even for the // repo OWNER. These routes are merged outside the CsrfRouter/origin_gate tree, so // a cookie-authed push would be drivable cross-origin with only git wire-format // friction. Push now requires a push-scoped PAT; the owner's browser session does // not authorize a write. #[tokio::test] async fn git_session_cookie_cannot_push() { let tmp = tempfile::TempDir::new().unwrap(); make_test_repo(tmp.path()); let mut h = setup_git_harness(&tmp).await; // signs up + logs in testowner h.client.get("/git/testowner/testrepo").await; // auto-register // Owner is logged in (session cookie tracked by the client) and CAN read. h.login("testowner", "password123").await; // Read advertisement (upload-pack) works with the cookie, owner has read. let upload = "/git/testowner/testrepo.git/info/refs?service=git-upload-pack"; let resp = h.client.get(upload).await; assert!( resp.status.is_success(), "owner cookie should read: {} {}", resp.status, resp.text ); // Push advertisement (receive-pack) must be REFUSED for cookie auth, no PAT. let recv = "/git/testowner/testrepo.git/info/refs?service=git-receive-pack"; let resp = h.client.get(recv).await; assert_eq!( resp.status, 403, "a session cookie must not authorize git push, even for the owner (UX-S1): {} {}", resp.status, resp.text ); // The receive-pack POST (the actual push) is likewise refused for cookie auth. let recv_post = "/git/testowner/testrepo.git/git-receive-pack"; let resp = h .client .request_with_headers( "POST", recv_post, Some("0000"), &[("Content-Type", "application/x-git-receive-pack-request")], ) .await; assert_eq!( resp.status, 403, "cookie-authed receive-pack POST must be refused (UX-S1): {} {}", resp.status, resp.text ); } // ── Smart HTTP (git clone) ── /// Tip commit sha of refs/heads/main from the on-disk bare repo. fn main_tip_sha(dir: &std::path::Path) -> String { let bare = dir.join("testowner").join("testrepo.git"); let repo = git2::Repository::open_bare(&bare).unwrap(); repo.refname_to_id("refs/heads/main").unwrap().to_string() } #[tokio::test] async fn git_smart_http_info_refs_advertises_refs() { 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.git/info/refs?service=git-upload-pack") .await; assert!( resp.status.is_success(), "info/refs failed: {} {}", resp.status, resp.text ); let ct = resp .headers .get("content-type") .and_then(|v| v.to_str().ok()) .unwrap_or(""); assert_eq!( ct, "application/x-git-upload-pack-advertisement", "wrong content-type: {ct}" ); assert!( resp.text.contains("# service=git-upload-pack"), "missing service banner: {}", resp.text ); assert!( resp.text.contains("refs/heads/main"), "advertisement missing main ref: {}", resp.text ); } #[tokio::test] async fn git_smart_http_upload_pack_streams_packfile() { // Exercises the streamed (Body::from_stream) upload-pack response added in // Run #20 (Performance HIGH): a real clone negotiation must still produce a // valid packfile, proving the stream + concurrency-permit path didn't // corrupt the protocol framing. let tmp = tempfile::TempDir::new().unwrap(); make_test_repo(tmp.path()); let sha = main_tip_sha(tmp.path()); let mut h = setup_git_harness(&tmp).await; // Minimal upload-pack request: one want line (capabilities ride the first // line; no side-band so the pack returns raw), flush, then done. let want = format!("want {sha} ofs-delta agent=git/test\n"); let body = format!("{:04x}{want}00000009done\n", want.len() + 4); let resp = h .client .request_with_headers( "POST", "/git/testowner/testrepo.git/git-upload-pack", Some(&body), &[("Content-Type", "application/x-git-upload-pack-request")], ) .await; assert!( resp.status.is_success(), "upload-pack failed: {} {}", resp.status, resp.text ); let ct = resp .headers .get("content-type") .and_then(|v| v.to_str().ok()) .unwrap_or(""); assert_eq!( ct, "application/x-git-upload-pack-result", "wrong content-type: {ct}" ); // The streamed body must carry the packfile magic, ASCII "PACK" survives // the lossy-UTF8 view the test client exposes. assert!( resp.text.contains("PACK"), "streamed response carried no packfile ({} bytes)", resp.text.len() ); }