//! GRASP-01 Push Authorization Tests //! //! Tests that verify push authorization works correctly according to GRASP-01: //! "MUST accept pushes via this service that match the latest repo state announcement //! on the relay, respecting the recursive maintainer set." //! //! ## Test Coverage //! //! - Push authorized when state event matches commit being pushed //! - Push rejected when no state event exists //! - Push rejected when state event has different commit //! //! ## Running Tests //! //! ```bash //! cd grasp-audit && nix develop -c bash test-ngit-relay.sh --mode test //! ``` use crate::{AuditClient, FixtureKind, TestContext, TestResult}; use nostr_sdk::prelude::*; use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; /// Test suite for Push Authorization operations pub struct PushAuthorizationTests; /// Helper to clone a repository and return the path fn clone_repo( relay_domain: &str, npub: &str, repo_id: &str, ) -> Result { let temp_base = std::env::temp_dir(); let clone_dir_name = format!("grasp-push-test-{}", uuid::Uuid::new_v4()); let clone_path = temp_base.join(&clone_dir_name); let _ = fs::remove_dir_all(&clone_path); let clone_url = format!("http://{}/{}/{}.git", relay_domain, npub, repo_id); let output = Command::new("git") .args(["clone", &clone_url, clone_path.to_str().unwrap()]) .env("GIT_TERMINAL_PROMPT", "0") .output() .map_err(|e| format!("Failed to execute git clone: {}", e))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(format!("Git clone failed: {}", stderr)); } // Configure git user let _ = Command::new("git") .args(["config", "user.email", "test@grasp-audit.local"]) .current_dir(&clone_path) .output(); let _ = Command::new("git") .args(["config", "user.name", "GRASP Audit Test"]) .current_dir(&clone_path) .output(); Ok(clone_path) } /// Helper to create a commit and return the hash fn create_commit(clone_path: &Path, message: &str) -> Result { let test_file = clone_path.join(format!("test-{}.txt", uuid::Uuid::new_v4())); fs::write(&test_file, message).map_err(|e| format!("Failed to write file: {}", e))?; let filename = test_file.file_name().unwrap().to_str().unwrap(); let output = Command::new("git") .args(["add", filename]) .current_dir(clone_path) .output() .map_err(|e| format!("Git add failed: {}", e))?; if !output.status.success() { return Err("Git add failed".to_string()); } let output = Command::new("git") .args(["commit", "-m", message]) .current_dir(clone_path) .output() .map_err(|e| format!("Git commit failed: {}", e))?; if !output.status.success() { return Err("Git commit failed".to_string()); } let output = Command::new("git") .args(["rev-parse", "HEAD"]) .current_dir(clone_path) .output() .map_err(|e| format!("Git rev-parse failed: {}", e))?; if !output.status.success() { return Err("Failed to get commit hash".to_string()); } Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } /// Variant of deterministic commit for different pubkey types /// Each variant produces a different but reproducible commit hash #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CommitVariant { /// Main pubkey variant - uses "Initial commit" content Owner, /// Maintainer pubkey variant - uses "Maintainer initial commit" content Maintainer, /// Recursive maintainer pubkey variant - uses "Recursive maintainer initial commit" content RecursiveMaintainer, } impl CommitVariant { /// Get the file content for this variant pub fn file_content(&self) -> &'static str { match self { CommitVariant::Owner => "Initial commit", CommitVariant::Maintainer => "Maintainer initial commit", CommitVariant::RecursiveMaintainer => "Recursive maintainer initial commit", } } /// Get the commit message for this variant pub fn commit_message(&self) -> &'static str { match self { CommitVariant::Owner => "Initial commit", CommitVariant::Maintainer => "Maintainer initial commit", CommitVariant::RecursiveMaintainer => "Recursive maintainer initial commit", } } } /// Helper to create a deterministic commit (for fixtures) /// Uses fixed author/committer dates and disables GPG signing to ensure consistent hash /// /// The variant parameter allows different commit hashes for different pubkey types: /// - Owner: uses the original DETERMINISTIC_COMMIT_HASH /// - Maintainer: uses MAINTAINER_DETERMINISTIC_COMMIT_HASH /// - RecursiveMaintainer: uses RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH pub fn create_deterministic_commit_with_variant(clone_path: &Path, variant: CommitVariant) -> Result { let test_file = clone_path.join("test.txt"); let content = variant.file_content(); let message = variant.commit_message(); fs::write(&test_file, content).map_err(|e| format!("Failed to write file: {}", e))?; let output = Command::new("git") .args(["add", "test.txt"]) .current_dir(clone_path) .output() .map_err(|e| format!("Git add failed: {}", e))?; if !output.status.success() { return Err("Git add failed".to_string()); } // Create deterministic commit with fixed dates and GPG disabled let output = Command::new("git") .args([ "-c", "commit.gpgsign=false", "commit", "-m", message, ]) .env("GIT_AUTHOR_DATE", "2024-01-01T00:00:00Z") .env("GIT_COMMITTER_DATE", "2024-01-01T00:00:00Z") .current_dir(clone_path) .output() .map_err(|e| format!("Git commit failed: {}", e))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(format!("Git commit failed: {}", stderr)); } let output = Command::new("git") .args(["rev-parse", "HEAD"]) .current_dir(clone_path) .output() .map_err(|e| format!("Git rev-parse failed: {}", e))?; if !output.status.success() { return Err("Failed to get commit hash".to_string()); } Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } /// Helper to create a deterministic commit (for fixtures) - uses Owner variant /// Uses fixed author/committer dates and disables GPG signing to ensure consistent hash pub fn create_deterministic_commit(clone_path: &Path, _message: &str) -> Result { // Note: message parameter is ignored for backwards compatibility // The Owner variant always uses "Initial commit" create_deterministic_commit_with_variant(clone_path, CommitVariant::Owner) } /// Repository setup with deterministic commit /// This struct holds all the data needed for push authorization tests pub struct RepoSetup { pub clone_path: PathBuf, pub repo_id: String, pub npub: String, pub commit_hash: String, } impl Drop for RepoSetup { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.clone_path); } } /// Helper function to set up a repository with deterministic commit /// /// This performs all the common setup steps needed for push authorization tests: /// 1. Gets RepoState fixture (repo announcement + state event with deterministic commit) /// 2. Extracts repo_id and npub /// 3. Verifies repo exists on disk /// 4. Clones the repository /// 5. Creates deterministic commit locally /// 6. Verifies commit hash matches expected /// 7. Creates and checks out main branch /// 8. Pushes the commit so the grasp server has the state in the state event /// /// Returns RepoSetup which auto-cleans up the clone_path on drop pub async fn setup_repo_with_deterministic_commit( client: &AuditClient, git_data_dir: &Path, relay_domain: &str, ) -> Result { use crate::DETERMINISTIC_COMMIT_HASH; let ctx = TestContext::new(client); // Get RepoState fixture (includes repo announcement and state event with deterministic commit) let state_event = ctx.get_fixture(FixtureKind::RepoState).await .map_err(|e| format!("Failed to create repo state fixture: {}", e))?; tokio::time::sleep(std::time::Duration::from_millis(200)).await; // Extract repo_id from state event let repo_id = state_event.tags.iter().find(|t| t.kind() == TagKind::d()) .and_then(|t| t.content()) .ok_or("Missing repo_id")? .to_string(); let npub = state_event.pubkey.to_bech32() .map_err(|e| format!("Failed to convert pubkey to bech32: {}", e))?; // Verify repo exists let repo_path = git_data_dir.join(&npub).join(format!("{}.git", repo_id)); if !repo_path.exists() { return Err(format!("Repo not found: {}", repo_path.display())); } // Clone repo let clone_path = clone_repo(relay_domain, &npub, &repo_id)?; // Create deterministic commit locally (this will be the root commit with no parent) let commit_hash = create_deterministic_commit(&clone_path, "Initial commit") .map_err(|e| { let _ = fs::remove_dir_all(&clone_path); e })?; // Verify commit hash matches expected deterministic hash if commit_hash != DETERMINISTIC_COMMIT_HASH { let _ = fs::remove_dir_all(&clone_path); return Err(format!( "Commit hash mismatch: got {}, expected {}", commit_hash, DETERMINISTIC_COMMIT_HASH )); } // Create main branch pointing to our deterministic commit let branch_output = Command::new("git") .args(["branch", "main"]) .current_dir(&clone_path) .output() .map_err(|e| { let _ = fs::remove_dir_all(&clone_path); format!("Failed to create main branch: {}", e) })?; if !branch_output.status.success() { let _ = fs::remove_dir_all(&clone_path); return Err(format!( "Failed to create main branch: {}", String::from_utf8_lossy(&branch_output.stderr) )); } // Checkout main branch let checkout_output = Command::new("git") .args(["checkout", "main"]) .current_dir(&clone_path) .output() .map_err(|e| { let _ = fs::remove_dir_all(&clone_path); format!("Failed to checkout main branch: {}", e) })?; if !checkout_output.status.success() { let _ = fs::remove_dir_all(&clone_path); return Err(format!( "Failed to checkout main branch: {}", String::from_utf8_lossy(&checkout_output.stderr) )); } // Push the commit to the server so the bare repo matches the state event let push_output = Command::new("git") .args(["push", "origin", "main"]) .current_dir(&clone_path) .env("GIT_TERMINAL_PROMPT", "0") .output() .map_err(|e| { let _ = fs::remove_dir_all(&clone_path); format!("Failed to push to server: {}", e) })?; if !push_output.status.success() { let _ = fs::remove_dir_all(&clone_path); return Err(format!( "Failed to push to server: {}", String::from_utf8_lossy(&push_output.stderr) )); } Ok(RepoSetup { clone_path, repo_id, npub, commit_hash, }) } /// Helper function to set up a maintainer repository with deterministic commit (state only) /// /// This performs all the common setup steps needed for maintainer push authorization tests: /// 1. Gets RepoState fixture (owner's repo announcement + state event with owner's deterministic commit) /// 2. Gets MaintainerState fixture (maintainer's state event ONLY - no announcement) /// 3. Extracts repo_id and owner npub /// 4. Verifies repo exists on disk /// 5. Clones the repository using owner's npub /// 6. Creates maintainer deterministic commit locally /// 7. Verifies commit hash matches expected /// 8. Creates and checks out main branch /// 9. Pushes the commit so the grasp server has the state in the state event /// /// Note: This does NOT publish a maintainer announcement. For tests that need the /// maintainer announcement (like recursive maintainer tests), use setup_repo_for_recursive_maintainer /// which publishes MaintainerAnnouncement separately. /// /// Returns RepoSetup which auto-cleans up the clone_path on drop pub async fn setup_repo_for_maintainer( client: &AuditClient, git_data_dir: &Path, relay_domain: &str, ) -> Result { use crate::MAINTAINER_DETERMINISTIC_COMMIT_HASH; let ctx = TestContext::new(client); // Get RepoState fixture (includes owner's repo announcement and state event with owner's deterministic commit) let state_event = ctx.get_fixture(FixtureKind::RepoState).await .map_err(|e| format!("Failed to create repo state fixture: {}", e))?; // Get MaintainerState fixture ONLY (no announcement - tests state-only authorization) let _maintainer_state = ctx.get_fixture(FixtureKind::MaintainerState).await .map_err(|e| format!("Failed to create maintainer state fixture: {}", e))?; tokio::time::sleep(std::time::Duration::from_millis(200)).await; // Extract repo_id from state event let repo_id = state_event.tags.iter().find(|t| t.kind() == TagKind::d()) .and_then(|t| t.content()) .ok_or("Missing repo_id")? .to_string(); // The npub is from the owner keys (the signer of the state event) let npub = state_event.pubkey.to_bech32() .map_err(|e| format!("Failed to convert owner pubkey to bech32: {}", e))?; // Verify repo exists let repo_path = git_data_dir.join(&npub).join(format!("{}.git", repo_id)); if !repo_path.exists() { return Err(format!("Owner repo not found: {}", repo_path.display())); } // Clone repo using owner's npub let clone_path = clone_repo(relay_domain, &npub, &repo_id)?; // Create maintainer deterministic commit locally (this will be the root commit with no parent) let commit_hash = create_deterministic_commit_with_variant(&clone_path, CommitVariant::Maintainer) .map_err(|e| { let _ = fs::remove_dir_all(&clone_path); e })?; // Verify commit hash matches expected maintainer deterministic hash if commit_hash != MAINTAINER_DETERMINISTIC_COMMIT_HASH { let _ = fs::remove_dir_all(&clone_path); return Err(format!( "Maintainer commit hash mismatch: got {}, expected {}", commit_hash, MAINTAINER_DETERMINISTIC_COMMIT_HASH )); } // Create main branch pointing to our deterministic commit let branch_output = Command::new("git") .args(["branch", "main"]) .current_dir(&clone_path) .output() .map_err(|e| { let _ = fs::remove_dir_all(&clone_path); format!("Failed to create main branch: {}", e) })?; if !branch_output.status.success() { let _ = fs::remove_dir_all(&clone_path); return Err(format!( "Failed to create main branch: {}", String::from_utf8_lossy(&branch_output.stderr) )); } // Checkout main branch let checkout_output = Command::new("git") .args(["checkout", "main"]) .current_dir(&clone_path) .output() .map_err(|e| { let _ = fs::remove_dir_all(&clone_path); format!("Failed to checkout main branch: {}", e) })?; if !checkout_output.status.success() { let _ = fs::remove_dir_all(&clone_path); return Err(format!( "Failed to checkout main branch: {}", String::from_utf8_lossy(&checkout_output.stderr) )); } // Push the commit to the server so the bare repo matches the state event let push_output = Command::new("git") .args(["push", "origin", "main"]) .current_dir(&clone_path) .env("GIT_TERMINAL_PROMPT", "0") .output() .map_err(|e| { let _ = fs::remove_dir_all(&clone_path); format!("Failed to push to server: {}", e) })?; if !push_output.status.success() { let _ = fs::remove_dir_all(&clone_path); return Err(format!( "Failed to push to server: {}", String::from_utf8_lossy(&push_output.stderr) )); } Ok(RepoSetup { clone_path, repo_id, npub, commit_hash, }) } /// Helper function to set up a recursive maintainer repository with deterministic commit /// /// This performs all the common setup steps needed for recursive maintainer push authorization tests: /// 1. Gets RepoState fixture (owner's repo announcement + state event with owner's deterministic commit) /// 2. Gets MaintainerAnnouncement fixture (maintainer's repo announcement with recursive maintainer in maintainers tag) /// 3. Gets MaintainerState fixture (maintainer's state event) /// 4. Gets RecursiveMaintainerRepoAndState fixture (recursive maintainer's repo - completes 3-level chain) /// 5. Extracts repo_id and owner npub /// 6. Verifies repo exists on disk /// 7. Clones the repository using owner's npub /// 8. Creates recursive maintainer deterministic commit locally /// 9. Verifies commit hash matches expected /// 10. Creates and checks out main branch /// 11. Pushes the commit so the grasp server has the state in the state event /// /// Returns RepoSetup which auto-cleans up the clone_path on drop pub async fn setup_repo_for_recursive_maintainer( client: &AuditClient, git_data_dir: &Path, relay_domain: &str, ) -> Result { use crate::RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH; let ctx = TestContext::new(client); // Get RepoState fixture (includes owner's repo announcement and state event) let state_event = ctx.get_fixture(FixtureKind::RepoState).await .map_err(|e| format!("Failed to create repo state fixture: {}", e))?; // Get MaintainerAnnouncement fixture (maintainer's repo announcement with recursive maintainer in maintainers tag) let _maintainer_announcement = ctx.get_fixture(FixtureKind::MaintainerAnnouncement).await .map_err(|e| format!("Failed to create maintainer announcement fixture: {}", e))?; // Get MaintainerState fixture (maintainer's state event) let _maintainer_state = ctx.get_fixture(FixtureKind::MaintainerState).await .map_err(|e| format!("Failed to create maintainer state fixture: {}", e))?; // Get RecursiveMaintainerRepoAndState fixture (completes 3-level delegation chain) let _recursive_maintainer_state = ctx.get_fixture(FixtureKind::RecursiveMaintainerRepoAndState).await .map_err(|e| format!("Failed to create recursive maintainer repo state fixture: {}", e))?; tokio::time::sleep(std::time::Duration::from_millis(200)).await; // Extract repo_id from owner's state event let repo_id = state_event.tags.iter().find(|t| t.kind() == TagKind::d()) .and_then(|t| t.content()) .ok_or("Missing repo_id")? .to_string(); // The npub is from the owner keys (the signer of the state event) let npub = state_event.pubkey.to_bech32() .map_err(|e| format!("Failed to convert owner pubkey to bech32: {}", e))?; // Verify repo exists let repo_path = git_data_dir.join(&npub).join(format!("{}.git", repo_id)); if !repo_path.exists() { return Err(format!("Owner repo not found: {}", repo_path.display())); } // Clone repo using owner's npub let clone_path = clone_repo(relay_domain, &npub, &repo_id)?; // Create recursive maintainer deterministic commit locally (this will be the root commit with no parent) let commit_hash = create_deterministic_commit_with_variant(&clone_path, CommitVariant::RecursiveMaintainer) .map_err(|e| { let _ = fs::remove_dir_all(&clone_path); e })?; // Verify commit hash matches expected recursive maintainer deterministic hash if commit_hash != RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH { let _ = fs::remove_dir_all(&clone_path); return Err(format!( "Recursive maintainer commit hash mismatch: got {}, expected {}", commit_hash, RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH )); } // Create main branch pointing to our deterministic commit let branch_output = Command::new("git") .args(["branch", "main"]) .current_dir(&clone_path) .output() .map_err(|e| { let _ = fs::remove_dir_all(&clone_path); format!("Failed to create main branch: {}", e) })?; if !branch_output.status.success() { let _ = fs::remove_dir_all(&clone_path); return Err(format!( "Failed to create main branch: {}", String::from_utf8_lossy(&branch_output.stderr) )); } // Checkout main branch let checkout_output = Command::new("git") .args(["checkout", "main"]) .current_dir(&clone_path) .output() .map_err(|e| { let _ = fs::remove_dir_all(&clone_path); format!("Failed to checkout main branch: {}", e) })?; if !checkout_output.status.success() { let _ = fs::remove_dir_all(&clone_path); return Err(format!( "Failed to checkout main branch: {}", String::from_utf8_lossy(&checkout_output.stderr) )); } // Push the commit to the server so the bare repo matches the state event let push_output = Command::new("git") .args(["push", "origin", "main"]) .current_dir(&clone_path) .env("GIT_TERMINAL_PROMPT", "0") .output() .map_err(|e| { let _ = fs::remove_dir_all(&clone_path); format!("Failed to push to server: {}", e) })?; if !push_output.status.success() { let _ = fs::remove_dir_all(&clone_path); return Err(format!( "Failed to push to server: {}", String::from_utf8_lossy(&push_output.stderr) )); } Ok(RepoSetup { clone_path, repo_id, npub, commit_hash, }) } /// Helper to attempt a push and return success/failure fn try_push(clone_path: &Path) -> Result { let output = Command::new("git") .args(["push", "origin", "main"]) .current_dir(clone_path) .env("GIT_TERMINAL_PROMPT", "0") .output() .map_err(|e| format!("Failed to execute git push: {}", e))?; Ok(output.status.success()) } impl PushAuthorizationTests { /// Test that push is authorized when state event matches the commit /// /// GRASP-01: "MUST accept pushes via this service that match the latest /// repo state announcement on the relay" pub async fn test_push_authorized_by_owner_state( client: &AuditClient, git_data_dir: &Path, relay_domain: &str, ) -> TestResult { let test_name = "test_push_authorized_by_owner_state"; // this setup is exactly what we are testing match setup_repo_with_deterministic_commit(client, git_data_dir, relay_domain).await { Ok(_) => { return TestResult::new(test_name, "GRASP-01", "Push authorized with matching state").pass() }, Err(e) => { return TestResult::new(test_name, "GRASP-01", "Push authorized with matching state") .fail(&format!("Failed: {}", e)) } }; } /// Test that push is rejected when no state event exists pub async fn test_push_rejected_without_state_event( client: &AuditClient, git_data_dir: &Path, relay_domain: &str, ) -> TestResult { let test_name = "test_push_rejected_without_state_event"; let ctx = TestContext::new(client); // Create repository (no state event) let repo = match ctx.get_fixture(FixtureKind::ValidRepo).await { Ok(r) => r, Err(e) => { return TestResult::new(test_name, "GRASP-01", "Push rejected without state event") .fail(&format!("Failed to create repo: {}", e)) } }; tokio::time::sleep(std::time::Duration::from_millis(200)).await; let repo_id = repo.tags.iter().find(|t| t.kind() == TagKind::d()) .and_then(|t| t.content()).unwrap().to_string(); let npub = repo.pubkey.to_bech32().unwrap(); let repo_path = git_data_dir.join(&npub).join(format!("{}.git", repo_id)); if !repo_path.exists() { return TestResult::new(test_name, "GRASP-01", "Push rejected without state event") .fail(&format!("Repo not found: {}", repo_path.display())); } // Clone and create commit let clone_path = match clone_repo(relay_domain, &npub, &repo_id) { Ok(p) => p, Err(e) => return TestResult::new(test_name, "GRASP-01", "Push rejected without state event").fail(&e), }; let cleanup = || { let _ = fs::remove_dir_all(&clone_path); }; if let Err(e) = create_commit(&clone_path, "Unauthorized commit") { cleanup(); return TestResult::new(test_name, "GRASP-01", "Push rejected without state event").fail(&e); } // Do NOT publish state event - push should be rejected let push_result = try_push(&clone_path); cleanup(); match push_result { Ok(false) => TestResult::new(test_name, "GRASP-01", "Push rejected without state event").pass(), Ok(true) => TestResult::new(test_name, "GRASP-01", "Push rejected without state event").fail("Push accepted but should be rejected"), Err(e) => TestResult::new(test_name, "GRASP-01", "Push rejected without state event").fail(&e), } } /// Test that push is rejected when commit doesn't match state event /// /// This test verifies that the relay enforces state event authorization. /// The state event (from fixture) points to the deterministic commit which is /// already on the server. We create a new commit locally and try to push it. /// The push should be rejected because the new commit doesn't match what the /// state event announces. pub async fn test_push_rejected_wrong_commit( client: &AuditClient, git_data_dir: &Path, relay_domain: &str, ) -> TestResult { let test_name = "test_push_rejected_wrong_commit"; // Set up repository with deterministic commit // This creates a state event pointing to DETERMINISTIC_COMMIT_HASH and pushes that commit let setup = match setup_repo_with_deterministic_commit(client, git_data_dir, relay_domain).await { Ok(s) => s, Err(e) => { return TestResult::new(test_name, "GRASP-01", "Push rejected when commit not in state event") .fail(&format!("Setup failed: {}", e)) } }; // Create a new commit locally - this is NOT announced in any state event let new_commit = match create_commit(&setup.clone_path, "Unauthorized commit") { Ok(h) => h, Err(e) => { return TestResult::new(test_name, "GRASP-01", "Push rejected when commit not in state event") .fail(&format!("Failed to create commit: {}", e)) } }; // Try to push the new commit // This should be REJECTED because: // - The state event still points to the deterministic commit (setup.commit_hash) // - We're trying to push new_commit which is different // - The relay MUST reject pushes that don't match the announced state let push_result = try_push(&setup.clone_path); match push_result { Ok(false) => TestResult::new(test_name, "GRASP-01", "Push rejected when commit not in state event").pass(), Ok(true) => TestResult::new(test_name, "GRASP-01", "Push rejected when commit not in state event") .fail(&format!( "Push accepted but should be rejected. State event points to {}, but pushed {}", setup.commit_hash, new_commit )), Err(e) => TestResult::new(test_name, "GRASP-01", "Push rejected when commit not in state event").fail(&e), } } /// Test push authorized by maintainer state event only (no announcement) /// /// GRASP-01: "respecting the recursive maintainer set" /// This tests that a maintainer can authorize pushes with ONLY a state event, /// without publishing their own repo announcement. The maintainer is still /// listed in the owner's announcement, so they're a valid maintainer. /// /// Scenario: /// 1. Owner's repo announcement lists maintainer in maintainers tag /// 2. Maintainer publishes ONLY a state event (no announcement) /// 3. setup_repo_for_maintainer() clones, creates maintainer commit, verifies hash, pushes /// 4. The push should be ACCEPTED because maintainer's state event authorizes it pub async fn test_push_authorized_by_maintainer_state_only( client: &AuditClient, git_data_dir: &Path, relay_domain: &str, ) -> TestResult { let test_name = "test_push_authorized_by_maintainer_state_only"; // Use setup_repo_for_maintainer which publishes ONLY the state event, no announcement match setup_repo_for_maintainer(client, git_data_dir, relay_domain).await { Ok(_setup) => { // Push succeeded in setup - this means the relay accepted the push // authorized by the maintainer's state event alone TestResult::new( test_name, "GRASP-01", "Push authorized by maintainer state event only (no announcement)", ) .pass() } Err(e) => { // Check if this was specifically a push rejection if e.contains("Failed to push") { TestResult::new( test_name, "GRASP-01", "Push authorized by maintainer state event only (no announcement)", ) .fail(&format!( "Push was rejected but should have been accepted. \ The maintainer published a state event with a commit hash, \ and even without a separate announcement, the relay should \ authorize pushes matching this state event since the maintainer \ is listed in the owner's announcement. \ Error: {}", e )) } else { // Some other error during setup TestResult::new( test_name, "GRASP-01", "Push authorized by maintainer state event only (no announcement)", ) .fail(&format!("Setup failed: {}", e)) } } } } /// Test push authorized by recursive maintainer state event /// /// GRASP-01: "respecting the recursive maintainer set" /// This tests recursive maintainer chains: Owner -> MaintainerA -> MaintainerB /// /// Scenario: /// 1. RecursiveMaintainerRepoAndState fixture creates: /// - Repo announcement signed by recursive_maintainer keys /// - Lists main pubkey and maintainer pubkey in maintainers tag /// - State event with RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH (2s in past) /// 2. setup_repo_for_recursive_maintainer() clones, creates recursive maintainer commit, verifies hash, pushes /// 3. The push should be ACCEPTED because recursive maintainer's state event authorizes it pub async fn test_push_authorized_by_recursive_maintainer_state( client: &AuditClient, git_data_dir: &Path, relay_domain: &str, ) -> TestResult { let test_name = "test_push_authorized_by_recursive_maintainer_state"; // Use setup_repo_for_recursive_maintainer which leverages RecursiveMaintainerRepoAndState fixture // This does all the heavy lifting: // 1. Creates repo announcement signed by recursive maintainer keys // 2. Creates state event pointing to RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH // 3. Clones the repo // 4. Creates the recursive maintainer deterministic commit locally // 5. Verifies commit hash matches expected // 6. Creates main branch, checks it out, and pushes match setup_repo_for_recursive_maintainer(client, git_data_dir, relay_domain).await { Ok(_setup) => { // Push succeeded in setup - this means the relay accepted the push // authorized by the recursive maintainer's state event TestResult::new( test_name, "GRASP-01", "Push authorized by recursive maintainer state event", ) .pass() } Err(e) => { // Check if this was specifically a push rejection if e.contains("Failed to push") { TestResult::new( test_name, "GRASP-01", "Push authorized by recursive maintainer state event", ) .fail(&format!( "Push was rejected but should have been accepted. \ The recursive maintainer published a state event with a commit hash, \ and the relay should authorize pushes matching this state event \ through recursive maintainer traversal. \ Error: {}", e )) } else { // Some other error during setup TestResult::new( test_name, "GRASP-01", "Push authorized by recursive maintainer state event", ) .fail(&format!("Setup failed: {}", e)) } } } } /// Test that non-maintainer state event is ignored /// /// This test verifies that the relay ignores state events from non-maintainers. /// We set up a valid repo, then create a rogue state event signed by a different /// keypair (not the repo maintainer) that announces a different commit. The push /// should be rejected because the rogue state event is not authorized. pub async fn test_non_maintainer_state_rejected( client: &AuditClient, git_data_dir: &Path, relay_domain: &str, ) -> TestResult { let test_name = "test_non_maintainer_state_rejected"; // Set up repository with deterministic commit (signed by maintainer) let setup = match setup_repo_with_deterministic_commit(client, git_data_dir, relay_domain).await { Ok(s) => s, Err(e) => { return TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored") .fail(&format!("Setup failed: {}", e)) } }; // Create a new commit locally that we want to push let new_commit = match create_commit(&setup.clone_path, "New commit to push") { Ok(h) => h, Err(e) => { return TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored") .fail(&format!("Failed to create commit: {}", e)) } }; // Create a rogue keypair (NOT the maintainer) let rogue_keys = Keys::generate(); // Create a rogue state event announcing the new commit // This event has the correct repo_id but is signed by a non-maintainer let rogue_state = match client .event_builder(Kind::Custom(30618), "") .tag(Tag::identifier(&setup.repo_id)) .tag(Tag::custom( TagKind::custom("refs/heads/main"), vec![new_commit.clone()], )) .build(&rogue_keys) { Ok(e) => e, Err(e) => { return TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored") .fail(&format!("Failed to build rogue state event: {}", e)) } }; // Send the rogue state event using the raw client to bypass AuditClient's key check if let Err(e) = client.client().send_event(&rogue_state).await { return TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored") .fail(&format!("Failed to send rogue state event: {}", e)); } // Wait for event to propagate tokio::time::sleep(std::time::Duration::from_millis(200)).await; // Try to push the new commit // This should be REJECTED because: // - The rogue state event announces new_commit // - But the rogue state event is NOT signed by the maintainer // - The relay should ignore the rogue state event // - The valid state event (from setup) still points to the deterministic commit // - Therefore pushing new_commit should fail let push_result = try_push(&setup.clone_path); match push_result { Ok(false) => TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored").pass(), Ok(true) => TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored") .fail(&format!( "Push accepted but should be rejected. A non-maintainer (pubkey: {}) published \ a state event announcing commit {}, but the push was accepted. The relay should \ only accept state events from maintainers (pubkey: {}).", rogue_keys.public_key(), new_commit, client.public_key() )), Err(e) => TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored").fail(&e), } } } #[cfg(test)] mod tests { #[test] fn test_module_exists() { assert!(true); } }