From a6edb42dfc653b6826b59b7f296e0d0c4ee74557 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Wed, 26 Nov 2025 08:45:16 +0000 Subject: fix: parsing maintainers from announcement event --- grasp-audit/src/audit.rs | 78 +- grasp-audit/src/client.rs | 125 +++ .../src/specs/grasp01/push_authorization.rs | 1186 +++++++++++++++++++- 3 files changed, 1366 insertions(+), 23 deletions(-) (limited to 'grasp-audit/src') diff --git a/grasp-audit/src/audit.rs b/grasp-audit/src/audit.rs index 5e84409..b97ddb6 100644 --- a/grasp-audit/src/audit.rs +++ b/grasp-audit/src/audit.rs @@ -129,6 +129,7 @@ pub struct AuditEventBuilder { content: String, tags: Vec, config: AuditConfig, + custom_timestamp: Option, } impl AuditEventBuilder { @@ -139,6 +140,7 @@ impl AuditEventBuilder { content: content.into(), tags: Vec::new(), config, + custom_timestamp: None, } } @@ -154,14 +156,49 @@ impl AuditEventBuilder { self } + /// Set a custom timestamp for the event + /// + /// By default, events use the current time. Use this method to create + /// events with a specific timestamp, which is useful for testing + /// timestamp-based prioritization logic. + /// + /// # Example + /// + /// ```rust + /// use nostr_sdk::prelude::*; + /// use grasp_audit::{AuditConfig, AuditEventBuilder}; + /// + /// let config = AuditConfig::ci(); + /// let keys = Keys::generate(); + /// + /// // Create an event with a past timestamp + /// let past_event = AuditEventBuilder::new(Kind::TextNote, "test", config) + /// .custom_time(Timestamp::from(1700000000)) + /// .build(&keys) + /// .unwrap(); + /// + /// assert_eq!(past_event.created_at, Timestamp::from(1700000000)); + /// ``` + pub fn custom_time(mut self, timestamp: Timestamp) -> Self { + self.custom_timestamp = Some(timestamp); + self + } + /// Build the event with audit tags pub fn build(self, keys: &Keys) -> anyhow::Result { let mut all_tags = self.tags; all_tags.extend(self.config.audit_tags()); - let event = EventBuilder::new(self.kind, self.content) - .tags(all_tags) - .sign_with_keys(keys)?; + let builder = EventBuilder::new(self.kind, self.content).tags(all_tags); + + // Apply custom timestamp if set + let builder = if let Some(timestamp) = self.custom_timestamp { + builder.custom_created_at(timestamp) + } else { + builder + }; + + let event = builder.sign_with_keys(keys)?; Ok(event) } @@ -243,4 +280,39 @@ mod tests { // Verify event is valid assert!(event.verify().is_ok()); } + + #[test] + fn test_custom_timestamp_applied() { + let config = AuditConfig::ci(); + let keys = Keys::generate(); + let custom_ts = Timestamp::from(1700000000); + + // Build event with custom timestamp + let event = AuditEventBuilder::new(Kind::TextNote, "test with custom time", config.clone()) + .custom_time(custom_ts) + .build(&keys) + .unwrap(); + + // Verify the custom timestamp was applied + assert_eq!(event.created_at, custom_ts); + + // Verify event is still valid + assert!(event.verify().is_ok()); + } + + #[test] + fn test_default_timestamp_uses_current_time() { + let config = AuditConfig::ci(); + let keys = Keys::generate(); + + let before = Timestamp::now(); + let event = AuditEventBuilder::new(Kind::TextNote, "test default time", config.clone()) + .build(&keys) + .unwrap(); + let after = Timestamp::now(); + + // Event timestamp should be between before and after (inclusive) + assert!(event.created_at.as_u64() >= before.as_u64()); + assert!(event.created_at.as_u64() <= after.as_u64()); + } } diff --git a/grasp-audit/src/client.rs b/grasp-audit/src/client.rs index 35aaccd..b2a4e38 100644 --- a/grasp-audit/src/client.rs +++ b/grasp-audit/src/client.rs @@ -286,6 +286,80 @@ impl AuditClient { Ok(event) } + /// Create a NIP-34 repository announcement event with maintainers + /// + /// This helper creates a properly formatted NIP-34 announcement that will be + /// accepted by GRASP relays (which require events to list the relay in clone/relays tags). + /// This variant also includes a maintainers tag for push authorization testing. + /// + /// # Arguments + /// * `test_name` - Name of the test (used to create unique repo identifier) + /// * `maintainer_pubkeys` - Hex pubkeys of maintainers who can push to the repository + /// + /// # Returns + /// A built and signed Event ready to be sent to the relay + pub async fn create_repo_announcement_with_maintainers( + &self, + test_name: &str, + maintainer_pubkeys: &[String], + ) -> Result { + // Get relay URL from client + let relay_url = self + .client + .relays() + .await + .keys() + .next() + .ok_or_else(|| anyhow!("No relay connected"))? + .to_string(); + + // Convert WebSocket URL to HTTP URL for clone tag + let http_url = relay_url + .replace("ws://", "http://") + .replace("wss://", "https://"); + + // Create unique repository identifier using UUID for consistency + let repo_id = format!("{}-{}", test_name, &uuid::Uuid::new_v4().to_string()[..8]); + + // Get npub for clone URL + let npub = self + .public_key() + .to_bech32() + .map_err(|e| anyhow!("Failed to convert public key to bech32 npub format: {}", e))?; + + // Build kind 30617 repository announcement with maintainers tag + let event = self + .event_builder( + Kind::GitRepoAnnouncement, + format!("Test repository for {}", test_name), + ) + .tag(Tag::identifier(&repo_id)) + .tag(Tag::custom( + TagKind::custom("name"), + vec![format!("{} Test Repository", test_name)], + )) + .tag(Tag::custom( + TagKind::custom("description"), + vec![format!("Repository for {} testing", test_name)], + )) + .tag(Tag::custom( + TagKind::custom("clone"), + vec![format!("{}/{}/{}.git", http_url, npub, repo_id)], + )) + .tag(Tag::custom( + TagKind::custom("relays"), + vec![relay_url.clone()], + )) + .tag(Tag::custom( + TagKind::custom("maintainers"), + maintainer_pubkeys.to_vec(), + )) + .build(self.keys()) + .map_err(|e| anyhow!("Failed to build repository announcement event: {}", e))?; + + Ok(event) + } + /// Create an issue (kind 1621) that references a repository /// /// # Arguments @@ -456,4 +530,55 @@ mod tests { "Missing custom tag value" ); } + + #[tokio::test] + async fn test_create_repo_announcement_with_maintainers() { + let config = AuditConfig::ci(); + let client = AuditClient::new_test(config); + + // Create test maintainer pubkeys (hex format) + let maintainer_pubkeys = vec![ + "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2".to_string(), + "b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3".to_string(), + ]; + + // Note: We can't test create_repo_announcement_with_maintainers directly in unit tests + // because it requires a connected relay. Instead, we test the underlying event building + // with maintainers tag to verify the tag format is correct. + + // Build an event with maintainers tag directly to test the tag format + let event = client + .event_builder( + Kind::GitRepoAnnouncement, + "Test repository", + ) + .tag(Tag::identifier("test-repo")) + .tag(Tag::custom( + TagKind::custom("maintainers"), + maintainer_pubkeys.clone(), + )) + .build(client.keys()) + .unwrap(); + + // Verify the maintainers tag is present and correctly formatted + let maintainers_tag = event + .tags + .iter() + .find(|t| t.kind() == TagKind::custom("maintainers")); + + assert!( + maintainers_tag.is_some(), + "Missing 'maintainers' tag in event" + ); + + // Verify the tag contains the maintainer pubkeys + let tag = maintainers_tag.unwrap(); + let tag_vec: Vec = tag.clone().to_vec(); + + // First element is "maintainers", rest are the pubkeys + assert_eq!(tag_vec[0], "maintainers"); + assert_eq!(tag_vec.len(), 3, "Expected 3 elements: tag name + 2 pubkeys"); + assert_eq!(tag_vec[1], maintainer_pubkeys[0]); + assert_eq!(tag_vec[2], maintainer_pubkeys[1]); + } } diff --git a/grasp-audit/src/specs/grasp01/push_authorization.rs b/grasp-audit/src/specs/grasp01/push_authorization.rs index 974ccd4..5545b1a 100644 --- a/grasp-audit/src/specs/grasp01/push_authorization.rs +++ b/grasp-audit/src/specs/grasp01/push_authorization.rs @@ -426,34 +426,907 @@ impl PushAuthorizationTests { } } - /// Test recursive maintainer authorization + /// Test that latest state event is used for authorization + /// + /// GRASP-01 requires that the relay use the LATEST state event (by created_at + /// timestamp) when determining push authorization. This test verifies that + /// a newer state event takes precedence over an older one. + /// + /// Scenario: + /// 1. Owner creates repo with maintainer + /// 2. Owner publishes state event for commit_a at t=100 (older) + /// 3. Maintainer publishes state event for commit_b at t=200 (newer) + /// 4. Push commit_b should be ACCEPTED (newer timestamp wins) + /// 5. Push commit_a should be REJECTED (older state event superseded) + pub async fn test_latest_state_event_used( + client: &AuditClient, + git_data_dir: &Path, + relay_domain: &str, + ) -> TestResult { + let test_name = "test_latest_state_event_used"; + let description = "Latest state event takes precedence"; + + // 1. Generate maintainer keypair + let maintainer_keys = Keys::generate(); + let maintainer_pubkey = maintainer_keys.public_key().to_hex(); + + // 2. Owner creates repo with maintainer + let repo_event = match client + .create_repo_announcement_with_maintainers(test_name, &[maintainer_pubkey.clone()]) + .await + { + Ok(e) => e, + Err(e) => { + return TestResult::new(test_name, "GRASP-01", description) + .fail(&format!("Failed to create repo with maintainers: {}", e)) + } + }; + + // Send the owner's repo event + if let Err(e) = client.send_event(repo_event.clone()).await { + return TestResult::new(test_name, "GRASP-01", description) + .fail(&format!("Failed to send owner repo event: {}", e)); + } + + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + + // Extract repo details + let repo_id = match repo_event + .tags + .iter() + .find(|t| t.kind() == TagKind::d()) + .and_then(|t| t.content()) + { + Some(id) => id.to_string(), + None => { + return TestResult::new(test_name, "GRASP-01", description) + .fail("Repository event missing d tag") + } + }; + + // Get relay URL for maintainer's repo announcement + let relay_url = match client.relay_url().await { + Ok(u) => u, + Err(e) => { + return TestResult::new(test_name, "GRASP-01", description) + .fail(&format!("Failed to get relay URL: {}", e)) + } + }; + let http_url = relay_url + .replace("ws://", "http://") + .replace("wss://", "https://"); + let maintainer_npub = match maintainer_keys.public_key().to_bech32() { + Ok(n) => n, + Err(e) => { + return TestResult::new(test_name, "GRASP-01", description) + .fail(&format!("Failed to convert maintainer pubkey to npub: {}", e)) + } + }; + + // 3. Maintainer creates their own repo announcement (same d-tag) + let maintainer_repo_event = match client + .event_builder( + Kind::GitRepoAnnouncement, + format!("Maintainer's view of {} repository", test_name), + ) + .tag(Tag::identifier(&repo_id)) + .tag(Tag::custom( + TagKind::custom("name"), + vec![format!("{} Test Repository (Maintainer)", test_name)], + )) + .tag(Tag::custom( + TagKind::custom("clone"), + vec![format!("{}/{}/{}.git", http_url, maintainer_npub, repo_id)], + )) + .tag(Tag::custom( + TagKind::custom("relays"), + vec![relay_url.clone()], + )) + .build(&maintainer_keys) + { + Ok(e) => e, + Err(e) => { + return TestResult::new(test_name, "GRASP-01", description) + .fail(&format!("Failed to build maintainer repo event: {}", e)) + } + }; + + if let Err(e) = client.client().send_event(&maintainer_repo_event).await { + return TestResult::new(test_name, "GRASP-01", description) + .fail(&format!("Failed to send maintainer repo event: {}", e)); + } + + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + + // Verify maintainer's repo was created + let maintainer_repo_path = git_data_dir + .join(&maintainer_npub) + .join(format!("{}.git", repo_id)); + if !maintainer_repo_path.exists() { + return TestResult::new(test_name, "GRASP-01", description).fail(&format!( + "Maintainer repo not created at: {}", + maintainer_repo_path.display() + )); + } + + // 4. Clone maintainer's repo + let clone_path = match clone_repo(relay_domain, &maintainer_npub, &repo_id) { + Ok(p) => p, + Err(e) => { + return TestResult::new(test_name, "GRASP-01", description) + .fail(&format!("Failed to clone maintainer repo: {}", e)) + } + }; + + // 5. Create first commit (commit_a) - this will be the one with OLDER timestamp + let commit_a = match create_commit(&clone_path, "Commit A - older state") { + Ok(h) => h, + Err(e) => { + let _ = fs::remove_dir_all(&clone_path); + return TestResult::new(test_name, "GRASP-01", description) + .fail(&format!("Failed to create commit_a: {}", e)); + } + }; + + // 6. Create second commit (commit_b) - this will be the one with NEWER timestamp + let commit_b = match create_commit(&clone_path, "Commit B - newer state") { + Ok(h) => h, + Err(e) => { + let _ = fs::remove_dir_all(&clone_path); + return TestResult::new(test_name, "GRASP-01", description) + .fail(&format!("Failed to create commit_b: {}", e)); + } + }; + + // 7. Calculate timestamps: older_timestamp (100 seconds ago) and newer_timestamp (now) + let base_time = Timestamp::now().as_u64(); + let older_timestamp = Timestamp::from(base_time - 100); // 100 seconds ago + let newer_timestamp = Timestamp::from(base_time); // now + + // 8. Owner publishes state event for commit_a at OLDER timestamp + let owner_state_event = match client + .event_builder(Kind::Custom(30618), "") + .tag(Tag::identifier(&repo_id)) + .tag(Tag::custom( + TagKind::custom("refs/heads/main"), + vec![commit_a.clone()], + )) + .custom_time(older_timestamp) + .build(client.keys()) + { + Ok(e) => e, + Err(e) => { + let _ = fs::remove_dir_all(&clone_path); + return TestResult::new(test_name, "GRASP-01", description) + .fail(&format!("Failed to build owner state event: {}", e)); + } + }; + + if let Err(e) = client.client().send_event(&owner_state_event).await { + let _ = fs::remove_dir_all(&clone_path); + return TestResult::new(test_name, "GRASP-01", description) + .fail(&format!("Failed to send owner state event: {}", e)); + } + + // 9. Maintainer publishes state event for commit_b at NEWER timestamp + let maintainer_state_event = match client + .event_builder(Kind::Custom(30618), "") + .tag(Tag::identifier(&repo_id)) + .tag(Tag::custom( + TagKind::custom("refs/heads/main"), + vec![commit_b.clone()], + )) + .custom_time(newer_timestamp) + .build(&maintainer_keys) + { + Ok(e) => e, + Err(e) => { + let _ = fs::remove_dir_all(&clone_path); + return TestResult::new(test_name, "GRASP-01", description) + .fail(&format!("Failed to build maintainer state event: {}", e)); + } + }; + + if let Err(e) = client.client().send_event(&maintainer_state_event).await { + let _ = fs::remove_dir_all(&clone_path); + return TestResult::new(test_name, "GRASP-01", description) + .fail(&format!("Failed to send maintainer state event: {}", e)); + } + + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + + // 10. Create and checkout main branch pointing to commit_b (the newer state) + let branch_output = Command::new("git") + .args(["branch", "main"]) + .current_dir(&clone_path) + .output(); + + if let Ok(output) = branch_output { + if !output.status.success() { + let _ = fs::remove_dir_all(&clone_path); + return TestResult::new(test_name, "GRASP-01", description).fail(&format!( + "Failed to create main branch: {}", + String::from_utf8_lossy(&output.stderr) + )); + } + } + + let checkout_output = Command::new("git") + .args(["checkout", "main"]) + .current_dir(&clone_path) + .output(); + + if let Ok(output) = checkout_output { + if !output.status.success() { + let _ = fs::remove_dir_all(&clone_path); + return TestResult::new(test_name, "GRASP-01", description).fail(&format!( + "Failed to checkout main branch: {}", + String::from_utf8_lossy(&output.stderr) + )); + } + } + + // 11. Attempt push - should be ACCEPTED because maintainer's newer state event + // announces commit_b which is now HEAD of main + let push_result = try_push(&clone_path); + let _ = fs::remove_dir_all(&clone_path); + + match push_result { + Ok(true) => TestResult::new(test_name, "GRASP-01", description).pass(), + Ok(false) => TestResult::new(test_name, "GRASP-01", description).fail(&format!( + "Push was rejected but should have been accepted. \ + The maintainer published a state event at timestamp {} announcing commit_b ({}). \ + The owner published an older state event at timestamp {} announcing commit_a ({}). \ + The relay should use the NEWER state event (maintainer's) for authorization.", + newer_timestamp.as_u64(), + commit_b, + older_timestamp.as_u64(), + commit_a + )), + Err(e) => { + TestResult::new(test_name, "GRASP-01", description).fail(&format!("Push error: {}", e)) + } + } + } + + /// Test push authorized by direct maintainer state event /// /// GRASP-01: "respecting the recursive maintainer set" - pub async fn test_recursive_maintainer_authorization( - _client: &AuditClient, - _git_data_dir: &Path, - _relay_domain: &str, + /// This tests the first level: direct maintainers listed in the maintainers tag. + /// + /// Scenario: + /// 1. Owner creates repo with `["maintainers", ""]` tag + /// 2. Maintainer creates their own repo announcement (same d-tag) + /// 3. Maintainer publishes state event with a commit hash + /// 4. Push to that commit should be ACCEPTED + pub async fn test_push_authorized_by_direct_maintainer_state( + client: &AuditClient, + git_data_dir: &Path, + relay_domain: &str, ) -> TestResult { - let test_name = "test_recursive_maintainer_authorization"; + let test_name = "test_push_authorized_by_direct_maintainer_state"; + + // 1. Generate maintainer keypair + let maintainer_keys = Keys::generate(); + let maintainer_pubkey = maintainer_keys.public_key().to_hex(); + + // 2. Owner creates repo with maintainer listed + let repo_event = match client + .create_repo_announcement_with_maintainers(test_name, &[maintainer_pubkey.clone()]) + .await + { + Ok(e) => e, + Err(e) => { + return TestResult::new( + test_name, + "GRASP-01", + "Push authorized by direct maintainer state event", + ) + .fail(&format!("Failed to create repo with maintainers: {}", e)) + } + }; + + // Send the owner's repo event + if let Err(e) = client.send_event(repo_event.clone()).await { + return TestResult::new( + test_name, + "GRASP-01", + "Push authorized by direct maintainer state event", + ) + .fail(&format!("Failed to send owner repo event: {}", e)); + } + + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + + // Extract repo details + let repo_id = match repo_event + .tags + .iter() + .find(|t| t.kind() == TagKind::d()) + .and_then(|t| t.content()) + { + Some(id) => id.to_string(), + None => { + return TestResult::new( + test_name, + "GRASP-01", + "Push authorized by direct maintainer state event", + ) + .fail("Repository event missing d tag") + } + }; + + // Get relay URL for maintainer's repo announcement + let relay_url = match client.relay_url().await { + Ok(u) => u, + Err(e) => { + return TestResult::new( + test_name, + "GRASP-01", + "Push authorized by direct maintainer state event", + ) + .fail(&format!("Failed to get relay URL: {}", e)) + } + }; + let http_url = relay_url + .replace("ws://", "http://") + .replace("wss://", "https://"); + let maintainer_npub = match maintainer_keys.public_key().to_bech32() { + Ok(n) => n, + Err(e) => { + return TestResult::new( + test_name, + "GRASP-01", + "Push authorized by direct maintainer state event", + ) + .fail(&format!("Failed to convert maintainer pubkey to npub: {}", e)) + } + }; + + // 3. Maintainer creates their own repo announcement (same d-tag) + // This creates a separate repo at maintainer-npub/repo-id.git + let maintainer_repo_event = match client + .event_builder( + Kind::GitRepoAnnouncement, + format!("Maintainer's view of {} repository", test_name), + ) + .tag(Tag::identifier(&repo_id)) + .tag(Tag::custom( + TagKind::custom("name"), + vec![format!("{} Test Repository (Maintainer)", test_name)], + )) + .tag(Tag::custom( + TagKind::custom("clone"), + vec![format!("{}/{}/{}.git", http_url, maintainer_npub, repo_id)], + )) + .tag(Tag::custom( + TagKind::custom("relays"), + vec![relay_url.clone()], + )) + .build(&maintainer_keys) + { + Ok(e) => e, + Err(e) => { + return TestResult::new( + test_name, + "GRASP-01", + "Push authorized by direct maintainer state event", + ) + .fail(&format!("Failed to build maintainer repo event: {}", e)) + } + }; + + if let Err(e) = client.client().send_event(&maintainer_repo_event).await { + return TestResult::new( + test_name, + "GRASP-01", + "Push authorized by direct maintainer state event", + ) + .fail(&format!("Failed to send maintainer repo event: {}", e)); + } + + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + + // Verify maintainer's repo was created + let maintainer_repo_path = git_data_dir + .join(&maintainer_npub) + .join(format!("{}.git", repo_id)); + if !maintainer_repo_path.exists() { + return TestResult::new( + test_name, + "GRASP-01", + "Push authorized by direct maintainer state event", + ) + .fail(&format!( + "Maintainer repo not created at: {}", + maintainer_repo_path.display() + )); + } + + // 4. Clone maintainer's repo + let clone_path = match clone_repo(relay_domain, &maintainer_npub, &repo_id) { + Ok(p) => p, + Err(e) => { + return TestResult::new( + test_name, + "GRASP-01", + "Push authorized by direct maintainer state event", + ) + .fail(&format!("Failed to clone maintainer repo: {}", e)) + } + }; + + // 5. Create deterministic commit + let commit_hash = match create_deterministic_commit(&clone_path, "Initial commit") { + Ok(h) => h, + Err(e) => { + let _ = fs::remove_dir_all(&clone_path); + return TestResult::new( + test_name, + "GRASP-01", + "Push authorized by direct maintainer state event", + ) + .fail(&format!("Failed to create commit: {}", e)); + } + }; + + // 6. Maintainer publishes state event with commit hash + let state_event = match client + .event_builder(Kind::Custom(30618), "") + .tag(Tag::identifier(&repo_id)) + .tag(Tag::custom( + TagKind::custom("refs/heads/main"), + vec![commit_hash.clone()], + )) + .build(&maintainer_keys) + { + Ok(e) => e, + Err(e) => { + let _ = fs::remove_dir_all(&clone_path); + return TestResult::new( + test_name, + "GRASP-01", + "Push authorized by direct maintainer state event", + ) + .fail(&format!("Failed to build state event: {}", e)); + } + }; - // This test requires two separate clients (owner and maintainer) - // For now, return not implemented - TestResult::new(test_name, "GRASP-01", "Maintainer can authorize pushes") - .fail("Not implemented: requires multiple client support") + if let Err(e) = client.client().send_event(&state_event).await { + let _ = fs::remove_dir_all(&clone_path); + return TestResult::new( + test_name, + "GRASP-01", + "Push authorized by direct maintainer state event", + ) + .fail(&format!("Failed to send state event: {}", e)); + } + + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + + // 7. Create and checkout main branch + let branch_output = Command::new("git") + .args(["branch", "main"]) + .current_dir(&clone_path) + .output(); + + if let Ok(output) = branch_output { + if !output.status.success() { + let _ = fs::remove_dir_all(&clone_path); + return TestResult::new( + test_name, + "GRASP-01", + "Push authorized by direct maintainer state event", + ) + .fail(&format!( + "Failed to create main branch: {}", + String::from_utf8_lossy(&output.stderr) + )); + } + } + + let checkout_output = Command::new("git") + .args(["checkout", "main"]) + .current_dir(&clone_path) + .output(); + + if let Ok(output) = checkout_output { + if !output.status.success() { + let _ = fs::remove_dir_all(&clone_path); + return TestResult::new( + test_name, + "GRASP-01", + "Push authorized by direct maintainer state event", + ) + .fail(&format!( + "Failed to checkout main branch: {}", + String::from_utf8_lossy(&output.stderr) + )); + } + } + + // 8. Attempt push - should be ACCEPTED because maintainer's state event authorizes it + let push_result = try_push(&clone_path); + let _ = fs::remove_dir_all(&clone_path); + + match push_result { + Ok(true) => TestResult::new( + test_name, + "GRASP-01", + "Push authorized by direct maintainer state event", + ) + .pass(), + Ok(false) => TestResult::new( + test_name, + "GRASP-01", + "Push authorized by direct maintainer state event", + ) + .fail(&format!( + "Push was rejected but should have been accepted. \ + The maintainer (pubkey: {}) is listed in the owner's maintainers tag \ + and published a state event announcing commit {}. \ + The relay should authorize pushes matching this state event.", + maintainer_pubkey, commit_hash + )), + Err(e) => TestResult::new( + test_name, + "GRASP-01", + "Push authorized by direct maintainer state event", + ) + .fail(&format!("Push error: {}", e)), + } } - /// Test that latest state event is used for authorization - pub async fn test_latest_state_event_used( - _client: &AuditClient, - _git_data_dir: &Path, - _relay_domain: &str, + /// 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. Owner creates repo with `["maintainers", ""]` tag + /// 2. MaintainerA creates their own repo announcement (same d-tag) with MaintainerB + /// 3. MaintainerB creates their own repo announcement (same d-tag, no further maintainers) + /// 4. MaintainerB publishes state event with a commit hash + /// 5. Push to that commit should be ACCEPTED (recursive maintainer chain) + pub async fn test_push_authorized_by_recursive_maintainer_state( + client: &AuditClient, + git_data_dir: &Path, + relay_domain: &str, ) -> TestResult { - let test_name = "test_latest_state_event_used"; + let test_name = "test_push_authorized_by_recursive_maintainer_state"; + + // 1. Generate MaintainerA and MaintainerB keypairs + let maintainer_a_keys = Keys::generate(); + let maintainer_a_pubkey = maintainer_a_keys.public_key().to_hex(); + + let maintainer_b_keys = Keys::generate(); + let maintainer_b_pubkey = maintainer_b_keys.public_key().to_hex(); - // This test requires publishing multiple state events with timestamps - // and verifying the latest one is used - TestResult::new(test_name, "GRASP-01", "Latest state event takes precedence") - .fail("Not implemented: requires timestamp manipulation") + // 2. Owner creates repo with MaintainerA listed + let repo_event = match client + .create_repo_announcement_with_maintainers(test_name, &[maintainer_a_pubkey.clone()]) + .await + { + Ok(e) => e, + Err(e) => { + return TestResult::new( + test_name, + "GRASP-01", + "Push authorized by recursive maintainer state event", + ) + .fail(&format!("Failed to create repo with maintainers: {}", e)) + } + }; + + // Send the owner's repo event + if let Err(e) = client.send_event(repo_event.clone()).await { + return TestResult::new( + test_name, + "GRASP-01", + "Push authorized by recursive maintainer state event", + ) + .fail(&format!("Failed to send owner repo event: {}", e)); + } + + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + + // Extract repo details + let repo_id = match repo_event + .tags + .iter() + .find(|t| t.kind() == TagKind::d()) + .and_then(|t| t.content()) + { + Some(id) => id.to_string(), + None => { + return TestResult::new( + test_name, + "GRASP-01", + "Push authorized by recursive maintainer state event", + ) + .fail("Repository event missing d tag") + } + }; + + // Get relay URL for maintainers' repo announcements + let relay_url = match client.relay_url().await { + Ok(u) => u, + Err(e) => { + return TestResult::new( + test_name, + "GRASP-01", + "Push authorized by recursive maintainer state event", + ) + .fail(&format!("Failed to get relay URL: {}", e)) + } + }; + let http_url = relay_url + .replace("ws://", "http://") + .replace("wss://", "https://"); + + let maintainer_a_npub = match maintainer_a_keys.public_key().to_bech32() { + Ok(n) => n, + Err(e) => { + return TestResult::new( + test_name, + "GRASP-01", + "Push authorized by recursive maintainer state event", + ) + .fail(&format!("Failed to convert maintainer A pubkey to npub: {}", e)) + } + }; + + let maintainer_b_npub = match maintainer_b_keys.public_key().to_bech32() { + Ok(n) => n, + Err(e) => { + return TestResult::new( + test_name, + "GRASP-01", + "Push authorized by recursive maintainer state event", + ) + .fail(&format!("Failed to convert maintainer B pubkey to npub: {}", e)) + } + }; + + // 3. MaintainerA creates their own repo announcement (same d-tag) with MaintainerB listed + let maintainer_a_repo_event = match client + .event_builder( + Kind::GitRepoAnnouncement, + format!("MaintainerA's view of {} repository", test_name), + ) + .tag(Tag::identifier(&repo_id)) + .tag(Tag::custom( + TagKind::custom("name"), + vec![format!("{} Test Repository (MaintainerA)", test_name)], + )) + .tag(Tag::custom( + TagKind::custom("clone"), + vec![format!("{}/{}/{}.git", http_url, maintainer_a_npub, repo_id)], + )) + .tag(Tag::custom( + TagKind::custom("relays"), + vec![relay_url.clone()], + )) + .tag(Tag::custom( + TagKind::custom("maintainers"), + vec![maintainer_b_pubkey.clone()], + )) + .build(&maintainer_a_keys) + { + Ok(e) => e, + Err(e) => { + return TestResult::new( + test_name, + "GRASP-01", + "Push authorized by recursive maintainer state event", + ) + .fail(&format!("Failed to build maintainer A repo event: {}", e)) + } + }; + + if let Err(e) = client.client().send_event(&maintainer_a_repo_event).await { + return TestResult::new( + test_name, + "GRASP-01", + "Push authorized by recursive maintainer state event", + ) + .fail(&format!("Failed to send maintainer A repo event: {}", e)); + } + + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + + // 4. MaintainerB creates their own repo announcement (same d-tag, no further maintainers) + let maintainer_b_repo_event = match client + .event_builder( + Kind::GitRepoAnnouncement, + format!("MaintainerB's view of {} repository", test_name), + ) + .tag(Tag::identifier(&repo_id)) + .tag(Tag::custom( + TagKind::custom("name"), + vec![format!("{} Test Repository (MaintainerB)", test_name)], + )) + .tag(Tag::custom( + TagKind::custom("clone"), + vec![format!("{}/{}/{}.git", http_url, maintainer_b_npub, repo_id)], + )) + .tag(Tag::custom( + TagKind::custom("relays"), + vec![relay_url.clone()], + )) + .build(&maintainer_b_keys) + { + Ok(e) => e, + Err(e) => { + return TestResult::new( + test_name, + "GRASP-01", + "Push authorized by recursive maintainer state event", + ) + .fail(&format!("Failed to build maintainer B repo event: {}", e)) + } + }; + + if let Err(e) = client.client().send_event(&maintainer_b_repo_event).await { + return TestResult::new( + test_name, + "GRASP-01", + "Push authorized by recursive maintainer state event", + ) + .fail(&format!("Failed to send maintainer B repo event: {}", e)); + } + + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + + // Verify maintainer B's repo was created + let maintainer_b_repo_path = git_data_dir + .join(&maintainer_b_npub) + .join(format!("{}.git", repo_id)); + if !maintainer_b_repo_path.exists() { + return TestResult::new( + test_name, + "GRASP-01", + "Push authorized by recursive maintainer state event", + ) + .fail(&format!( + "Maintainer B repo not created at: {}", + maintainer_b_repo_path.display() + )); + } + + // 5. Clone maintainer B's repo + let clone_path = match clone_repo(relay_domain, &maintainer_b_npub, &repo_id) { + Ok(p) => p, + Err(e) => { + return TestResult::new( + test_name, + "GRASP-01", + "Push authorized by recursive maintainer state event", + ) + .fail(&format!("Failed to clone maintainer B repo: {}", e)) + } + }; + + // 6. Create deterministic commit + let commit_hash = match create_deterministic_commit(&clone_path, "Initial commit") { + Ok(h) => h, + Err(e) => { + let _ = fs::remove_dir_all(&clone_path); + return TestResult::new( + test_name, + "GRASP-01", + "Push authorized by recursive maintainer state event", + ) + .fail(&format!("Failed to create commit: {}", e)); + } + }; + + // 7. MaintainerB publishes state event with commit hash + let state_event = match client + .event_builder(Kind::Custom(30618), "") + .tag(Tag::identifier(&repo_id)) + .tag(Tag::custom( + TagKind::custom("refs/heads/main"), + vec![commit_hash.clone()], + )) + .build(&maintainer_b_keys) + { + Ok(e) => e, + Err(e) => { + let _ = fs::remove_dir_all(&clone_path); + return TestResult::new( + test_name, + "GRASP-01", + "Push authorized by recursive maintainer state event", + ) + .fail(&format!("Failed to build state event: {}", e)); + } + }; + + if let Err(e) = client.client().send_event(&state_event).await { + let _ = fs::remove_dir_all(&clone_path); + return TestResult::new( + test_name, + "GRASP-01", + "Push authorized by recursive maintainer state event", + ) + .fail(&format!("Failed to send state event: {}", e)); + } + + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + + // 8. Create and checkout main branch + let branch_output = Command::new("git") + .args(["branch", "main"]) + .current_dir(&clone_path) + .output(); + + if let Ok(output) = branch_output { + if !output.status.success() { + let _ = fs::remove_dir_all(&clone_path); + return TestResult::new( + test_name, + "GRASP-01", + "Push authorized by recursive maintainer state event", + ) + .fail(&format!( + "Failed to create main branch: {}", + String::from_utf8_lossy(&output.stderr) + )); + } + } + + let checkout_output = Command::new("git") + .args(["checkout", "main"]) + .current_dir(&clone_path) + .output(); + + if let Ok(output) = checkout_output { + if !output.status.success() { + let _ = fs::remove_dir_all(&clone_path); + return TestResult::new( + test_name, + "GRASP-01", + "Push authorized by recursive maintainer state event", + ) + .fail(&format!( + "Failed to checkout main branch: {}", + String::from_utf8_lossy(&output.stderr) + )); + } + } + + // 9. Attempt push - should be ACCEPTED because recursive maintainer chain authorizes it + // Owner -> MaintainerA -> MaintainerB, and MaintainerB has published the state event + let push_result = try_push(&clone_path); + let _ = fs::remove_dir_all(&clone_path); + + match push_result { + Ok(true) => TestResult::new( + test_name, + "GRASP-01", + "Push authorized by recursive maintainer state event", + ) + .pass(), + Ok(false) => 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 chain is: Owner -> MaintainerA (pubkey: {}) -> MaintainerB (pubkey: {}). \ + MaintainerB published a state event announcing commit {}. \ + The relay should authorize pushes matching this state event through recursive maintainer traversal.", + maintainer_a_pubkey, maintainer_b_pubkey, commit_hash + )), + Err(e) => TestResult::new( + test_name, + "GRASP-01", + "Push authorized by recursive maintainer state event", + ) + .fail(&format!("Push error: {}", e)), + } } /// Test that non-maintainer state event is ignored @@ -540,6 +1413,279 @@ impl PushAuthorizationTests { Err(e) => TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored").fail(&e), } } + + /// Test that owner's newer state event beats maintainer's older state event + /// + /// GRASP-01 requires that the relay use the LATEST state event (by created_at + /// timestamp) when determining push authorization. This test is the MIRROR of + /// test_latest_state_event_used - confirming that timestamp is the deciding factor, + /// not who authored the state event. + /// + /// Scenario: + /// 1. Owner creates repo with maintainer + /// 2. Maintainer publishes state event for commit_a at t=100 (older) + /// 3. Owner publishes state event for commit_b at t=200 (newer) + /// 4. Push commit_b should be ACCEPTED (owner's newer state wins) + /// 5. Push commit_a should be REJECTED (maintainer's older state superseded) + /// + /// Key difference from test_latest_state_event_used: + /// - Task 8: Owner=older, Maintainer=newer → Maintainer wins + /// - Task 9: Maintainer=older, Owner=newer → Owner wins + /// - **This confirms symmetry**: timestamp is the deciding factor + pub async fn test_owner_newer_state_beats_maintainer( + client: &AuditClient, + git_data_dir: &Path, + relay_domain: &str, + ) -> TestResult { + let test_name = "test_owner_newer_state_beats_maintainer"; + let description = "Owner's newer state event beats maintainer's older state"; + + // 1. Generate maintainer keypair + let maintainer_keys = Keys::generate(); + let maintainer_pubkey = maintainer_keys.public_key().to_hex(); + + // 2. Owner creates repo with maintainer + let repo_event = match client + .create_repo_announcement_with_maintainers(test_name, &[maintainer_pubkey.clone()]) + .await + { + Ok(e) => e, + Err(e) => { + return TestResult::new(test_name, "GRASP-01", description) + .fail(&format!("Failed to create repo with maintainers: {}", e)) + } + }; + + // Send the owner's repo event + if let Err(e) = client.send_event(repo_event.clone()).await { + return TestResult::new(test_name, "GRASP-01", description) + .fail(&format!("Failed to send owner repo event: {}", e)); + } + + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + + // Extract repo details + let repo_id = match repo_event + .tags + .iter() + .find(|t| t.kind() == TagKind::d()) + .and_then(|t| t.content()) + { + Some(id) => id.to_string(), + None => { + return TestResult::new(test_name, "GRASP-01", description) + .fail("Repository event missing d tag") + } + }; + + // Get relay URL for maintainer's repo announcement + let relay_url = match client.relay_url().await { + Ok(u) => u, + Err(e) => { + return TestResult::new(test_name, "GRASP-01", description) + .fail(&format!("Failed to get relay URL: {}", e)) + } + }; + let http_url = relay_url + .replace("ws://", "http://") + .replace("wss://", "https://"); + let maintainer_npub = match maintainer_keys.public_key().to_bech32() { + Ok(n) => n, + Err(e) => { + return TestResult::new(test_name, "GRASP-01", description) + .fail(&format!("Failed to convert maintainer pubkey to npub: {}", e)) + } + }; + + // 3. Maintainer creates their own repo announcement (same d-tag) + let maintainer_repo_event = match client + .event_builder( + Kind::GitRepoAnnouncement, + format!("Maintainer's view of {} repository", test_name), + ) + .tag(Tag::identifier(&repo_id)) + .tag(Tag::custom( + TagKind::custom("name"), + vec![format!("{} Test Repository (Maintainer)", test_name)], + )) + .tag(Tag::custom( + TagKind::custom("clone"), + vec![format!("{}/{}/{}.git", http_url, maintainer_npub, repo_id)], + )) + .tag(Tag::custom( + TagKind::custom("relays"), + vec![relay_url.clone()], + )) + .build(&maintainer_keys) + { + Ok(e) => e, + Err(e) => { + return TestResult::new(test_name, "GRASP-01", description) + .fail(&format!("Failed to build maintainer repo event: {}", e)) + } + }; + + if let Err(e) = client.client().send_event(&maintainer_repo_event).await { + return TestResult::new(test_name, "GRASP-01", description) + .fail(&format!("Failed to send maintainer repo event: {}", e)); + } + + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + + // Verify maintainer's repo was created + let maintainer_repo_path = git_data_dir + .join(&maintainer_npub) + .join(format!("{}.git", repo_id)); + if !maintainer_repo_path.exists() { + return TestResult::new(test_name, "GRASP-01", description).fail(&format!( + "Maintainer repo not created at: {}", + maintainer_repo_path.display() + )); + } + + // 4. Clone maintainer's repo + let clone_path = match clone_repo(relay_domain, &maintainer_npub, &repo_id) { + Ok(p) => p, + Err(e) => { + return TestResult::new(test_name, "GRASP-01", description) + .fail(&format!("Failed to clone maintainer repo: {}", e)) + } + }; + + // 5. Create first commit (commit_a) - MAINTAINER will announce this with OLDER timestamp + let commit_a = match create_commit(&clone_path, "Commit A - older state (maintainer)") { + Ok(h) => h, + Err(e) => { + let _ = fs::remove_dir_all(&clone_path); + return TestResult::new(test_name, "GRASP-01", description) + .fail(&format!("Failed to create commit_a: {}", e)); + } + }; + + // 6. Create second commit (commit_b) - OWNER will announce this with NEWER timestamp + let commit_b = match create_commit(&clone_path, "Commit B - newer state (owner)") { + Ok(h) => h, + Err(e) => { + let _ = fs::remove_dir_all(&clone_path); + return TestResult::new(test_name, "GRASP-01", description) + .fail(&format!("Failed to create commit_b: {}", e)); + } + }; + + // 7. Calculate timestamps: older_timestamp (100 seconds ago) and newer_timestamp (now) + let base_time = Timestamp::now().as_u64(); + let older_timestamp = Timestamp::from(base_time - 100); // 100 seconds ago - for MAINTAINER + let newer_timestamp = Timestamp::from(base_time); // now - for OWNER + + // 8. MAINTAINER publishes state event for commit_a at OLDER timestamp + // This is the KEY DIFFERENCE from test_latest_state_event_used: + // - In Task 8: Owner was older, Maintainer was newer + // - In Task 9 (this test): Maintainer is older, Owner is newer + let maintainer_state_event = match client + .event_builder(Kind::Custom(30618), "") + .tag(Tag::identifier(&repo_id)) + .tag(Tag::custom( + TagKind::custom("refs/heads/main"), + vec![commit_a.clone()], + )) + .custom_time(older_timestamp) + .build(&maintainer_keys) + { + Ok(e) => e, + Err(e) => { + let _ = fs::remove_dir_all(&clone_path); + return TestResult::new(test_name, "GRASP-01", description) + .fail(&format!("Failed to build maintainer state event: {}", e)); + } + }; + + if let Err(e) = client.client().send_event(&maintainer_state_event).await { + let _ = fs::remove_dir_all(&clone_path); + return TestResult::new(test_name, "GRASP-01", description) + .fail(&format!("Failed to send maintainer state event: {}", e)); + } + + // 9. OWNER publishes state event for commit_b at NEWER timestamp + let owner_state_event = match client + .event_builder(Kind::Custom(30618), "") + .tag(Tag::identifier(&repo_id)) + .tag(Tag::custom( + TagKind::custom("refs/heads/main"), + vec![commit_b.clone()], + )) + .custom_time(newer_timestamp) + .build(client.keys()) + { + Ok(e) => e, + Err(e) => { + let _ = fs::remove_dir_all(&clone_path); + return TestResult::new(test_name, "GRASP-01", description) + .fail(&format!("Failed to build owner state event: {}", e)); + } + }; + + if let Err(e) = client.client().send_event(&owner_state_event).await { + let _ = fs::remove_dir_all(&clone_path); + return TestResult::new(test_name, "GRASP-01", description) + .fail(&format!("Failed to send owner state event: {}", e)); + } + + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + + // 10. Create and checkout main branch pointing to commit_b (the newer state) + let branch_output = Command::new("git") + .args(["branch", "main"]) + .current_dir(&clone_path) + .output(); + + if let Ok(output) = branch_output { + if !output.status.success() { + let _ = fs::remove_dir_all(&clone_path); + return TestResult::new(test_name, "GRASP-01", description).fail(&format!( + "Failed to create main branch: {}", + String::from_utf8_lossy(&output.stderr) + )); + } + } + + let checkout_output = Command::new("git") + .args(["checkout", "main"]) + .current_dir(&clone_path) + .output(); + + if let Ok(output) = checkout_output { + if !output.status.success() { + let _ = fs::remove_dir_all(&clone_path); + return TestResult::new(test_name, "GRASP-01", description).fail(&format!( + "Failed to checkout main branch: {}", + String::from_utf8_lossy(&output.stderr) + )); + } + } + + // 11. Attempt push - should be ACCEPTED because owner's newer state event + // announces commit_b which is now HEAD of main + let push_result = try_push(&clone_path); + let _ = fs::remove_dir_all(&clone_path); + + match push_result { + Ok(true) => TestResult::new(test_name, "GRASP-01", description).pass(), + Ok(false) => TestResult::new(test_name, "GRASP-01", description).fail(&format!( + "Push was rejected but should have been accepted. \ + The OWNER published a state event at timestamp {} announcing commit_b ({}). \ + The MAINTAINER published an older state event at timestamp {} announcing commit_a ({}). \ + The relay should use the NEWER state event (owner's) for authorization. \ + This confirms symmetry with test_latest_state_event_used: timestamp is the deciding factor.", + newer_timestamp.as_u64(), + commit_b, + older_timestamp.as_u64(), + commit_a + )), + Err(e) => { + TestResult::new(test_name, "GRASP-01", description).fail(&format!("Push error: {}", e)) + } + } + } } #[cfg(test)] -- cgit v1.2.3