//! Git browser route tests: repo overview, tree, file, commits, raw, 404s. //! //! Creates temp bare repos with gitoxide 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) { use crate::harness::gitfixture::{blob, commit, init_bare, tree}; use gix::objs::tree::EntryKind; let repo = init_bare(dir, "testowner", "testrepo"); // Commit 1: README.md + src/main.rs let readme = blob(&repo, b"# Test Repo\n\nHello world."); let main_rs = blob(&repo, b"fn main() {\n println!(\"hello\");\n}\n"); let src = tree(&repo, &[("main.rs", main_rs, EntryKind::Blob)]); let root = tree( &repo, &[ ("README.md", readme, EntryKind::Blob), ("src", src, EntryKind::Tree), ], ); let first = commit(&repo, "Initial commit", root, Vec::new()); // Commit 2: modify src/main.rs let main_rs_v2 = blob( &repo, b"fn main() {\n println!(\"hello\");\n println!(\"world\");\n}\n", ); let src2 = tree(&repo, &[("main.rs", main_rs_v2, EntryKind::Blob)]); let root2 = tree( &repo, &[ ("README.md", readme, EntryKind::Blob), ("src", src2, EntryKind::Tree), ], ); commit(&repo, "Add world output", root2, vec![first]); } /// 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_eq!( resp.status, 200, "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_eq!( resp.status, 200, "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_eq!( resp.status, 200, "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_eq!( resp.status, 200, "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_eq!( resp.status, 200, "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_eq!( resp.status, 200, "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_eq!( 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_eq!(resp.status, 200, "{}", resp.text); // 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_eq!(resp.status, 200, "{}", resp.text); 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_eq!( resp.status, 200, "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_eq!(resp.status, 200, "{}", resp.text); 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_eq!( resp.status, 200, "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_eq!(log_resp.status, 200, "{}", log_resp.text); // 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_eq!( resp.status, 200, "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_eq!(log_resp.status, 200, "{}", log_resp.text); // 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_eq!( resp.status, 200, "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_eq!( resp.status, 200, "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_eq!(resp.status, 200, "{}", resp.text); let resp = h.client.get("/git/testowner").await; assert_eq!( resp.status, 200, "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_eq!(resp.status, 200, "{}", resp.text); 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_eq!(resp.status, 200, "{}", resp.text); 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. Auto-registration lands private since // migration 182, so publish it explicitly: what this test is about is the // explore page listing a public repo, not what the default happens to be. // Its sibling below covers the private case. let resp = h.client.get("/git/testowner/testrepo").await; assert_eq!(resp.status, 200, "{}", resp.text); sqlx::query("UPDATE git_repos SET visibility = 'public' WHERE name = 'testrepo'") .execute(&h.db) .await .unwrap(); 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_eq!(resp.status, 200, "{}", resp.text); 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_eq!( resp.status, 200, "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_eq!( resp.status, 200, "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_eq!( resp.status, 200, "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_eq!(resp.status, 200, "{}", resp.text); 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_eq!(resp.status, 200, "{}", resp.text); 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_eq!(resp.status, 200, "{}", resp.text); 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_eq!( resp.status, 200, "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_eq!( resp.status, 200, "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_eq!(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_eq!( resp.status, 200, "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_eq!( resp.status, 200, "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 ); } // Suspension has to mean the same thing over HTTPS that it means over SSH. // `git_ssh::dispatch` loads the user and refuses a suspended or deactivated // account; the HTTPS funnel used to authenticate a token on its hash and expiry // alone, so a token minted before a suspension kept working. Both credential // branches are covered here: the PAT and the session cookie. #[tokio::test] async fn suspended_account_loses_git_over_https() { 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; // auto-register 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_eq!(resp.status, 200, "create push token: {}", resp.text); let token = resp.text.trim().to_string(); let upload = "/git/testowner/testrepo.git/info/refs?service=git-upload-pack"; let recv = "/git/testowner/testrepo.git/info/refs?service=git-receive-pack"; // Baseline: the token reads and pushes, the cookie reads. The token half // runs with the cookies cleared, since the funnel prefers a session and a // session is not a push credential. h.client.clear_cookies(); let resp = h .client .request_with_headers( "GET", upload, None, &[("Authorization", &basic_auth(&token))], ) .await; assert_eq!( resp.status, 200, "token read before suspension: {}", resp.text ); let resp = h .client .request_with_headers("GET", recv, None, &[("Authorization", &basic_auth(&token))]) .await; assert_eq!( resp.status, 200, "token push advert before suspension: {}", resp.text ); h.login("testowner", "password123").await; let resp = h.client.get(upload).await; assert_eq!( resp.status, 200, "cookie read before suspension: {}", resp.text ); sqlx::query("UPDATE users SET suspended_at = now() WHERE username = 'testowner'") .execute(&h.db) .await .unwrap(); // The cookie branch: the session survives, the account does not. let resp = h.client.get(upload).await; assert_eq!( resp.status, 404, "a suspended account must not read over a session cookie: {}", resp.text ); // The token branch, read and push both. h.client.clear_cookies(); let resp = h .client .request_with_headers( "GET", upload, None, &[("Authorization", &basic_auth(&token))], ) .await; assert_eq!( resp.status, 404, "a suspended account must not clone over HTTPS: {}", resp.text ); let resp = h .client .request_with_headers("GET", recv, None, &[("Authorization", &basic_auth(&token))]) .await; assert_eq!( resp.status, 404, "a suspended account must not push over HTTPS: {}", resp.text ); // Deactivation is refused on the same terms, the way SSH pairs them. sqlx::query( "UPDATE users SET suspended_at = NULL, deactivated_at = now() WHERE username = 'testowner'", ) .execute(&h.db) .await .unwrap(); let resp = h .client .request_with_headers( "GET", upload, None, &[("Authorization", &basic_auth(&token))], ) .await; assert_eq!( resp.status, 404, "a deactivated account must not clone over HTTPS: {}", resp.text ); // Lifting the suspension restores the same token, so it is account standing // being enforced and not the token being invalidated. sqlx::query("UPDATE users SET deactivated_at = NULL WHERE username = 'testowner'") .execute(&h.db) .await .unwrap(); let resp = h .client .request_with_headers( "GET", upload, None, &[("Authorization", &basic_auth(&token))], ) .await; assert_eq!( resp.status, 200, "token should work again once the account is in good standing: {}", 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_eq!( resp.status, 200, "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 { crate::harness::gitfixture::main_tip_sha(dir, "testowner", "testrepo") } #[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_eq!( resp.status, 200, "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_eq!( resp.status, 200, "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() ); }