From 734d255efaa26bcb18b29d655bf30f8affb3a852 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Wed, 26 Nov 2025 15:36:12 +0000 Subject: test: use fixtures in push tests --- grasp-audit/src/client.rs | 147 +- grasp-audit/src/fixtures.rs | 378 ++++- grasp-audit/src/lib.rs | 5 +- grasp-audit/src/specs/grasp01/git_clone.rs | 2 +- grasp-audit/src/specs/grasp01/mod.rs | 2 +- .../src/specs/grasp01/push_authorization.rs | 1608 ++++++-------------- 6 files changed, 892 insertions(+), 1250 deletions(-) (limited to 'grasp-audit/src') diff --git a/grasp-audit/src/client.rs b/grasp-audit/src/client.rs index b2a4e38..8b96f4f 100644 --- a/grasp-audit/src/client.rs +++ b/grasp-audit/src/client.rs @@ -10,6 +10,10 @@ pub struct AuditClient { client: Client, pub config: AuditConfig, keys: Keys, + /// Maintainer keys for testing push authorization scenarios + maintainer_keys: Keys, + /// Recursive maintainer keys for testing recursive authorization scenarios + recursive_maintainer_keys: Keys, } impl AuditClient { @@ -17,17 +21,23 @@ impl AuditClient { #[cfg(test)] pub fn new_test(config: AuditConfig) -> Self { let keys = Keys::generate(); + let maintainer_keys = Keys::generate(); + let recursive_maintainer_keys = Keys::generate(); let client = Client::new(keys.clone()); Self { client, config, keys, + maintainer_keys, + recursive_maintainer_keys, } } /// Create a new audit client pub async fn new(relay_url: &str, config: AuditConfig) -> Result { let keys = Keys::generate(); + let maintainer_keys = Keys::generate(); + let recursive_maintainer_keys = Keys::generate(); let client = Client::new(keys.clone()); // Add relay and connect @@ -76,6 +86,8 @@ impl AuditClient { client, config, keys, + maintainer_keys, + recursive_maintainer_keys, }) } @@ -222,17 +234,45 @@ impl AuditClient { &self.keys } - /// Create a NIP-34 repository announcement event + /// Get the maintainer keys (for push authorization testing) + pub fn maintainer_keys(&self) -> &Keys { + &self.maintainer_keys + } + + /// Get the maintainer public key as a hex string + pub fn maintainer_pubkey_hex(&self) -> String { + self.maintainer_keys.public_key().to_hex() + } + + /// Get the recursive maintainer keys (for recursive authorization testing) + pub fn recursive_maintainer_keys(&self) -> &Keys { + &self.recursive_maintainer_keys + } + + /// Get the recursive maintainer public key as a hex string + pub fn recursive_maintainer_pubkey_hex(&self) -> String { + self.recursive_maintainer_keys.public_key().to_hex() + } + + /// Create a NIP-34 repository announcement event with full customization /// - /// 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 is the core method for creating repository announcements. It allows + /// specifying the signing keys and maintainers, making it suitable for all + /// repo creation scenarios including maintainer and recursive maintainer testing. /// /// # Arguments /// * `test_name` - Name of the test (used to create unique repo identifier) + /// * `signing_keys` - The keys to sign the event with (also used for clone URL) + /// * `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(&self, test_name: &str) -> Result { + /// A tuple of (Event, repo_id) - the built event and the repository identifier + pub async fn create_repo_announcement_custom( + &self, + test_name: &str, + signing_keys: &Keys, + maintainer_pubkeys: &[String], + ) -> Result<(Event, String)> { // Get relay URL from client let relay_url = self .client @@ -251,8 +291,8 @@ impl AuditClient { // 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 + // Get npub for clone URL from signing keys + let npub = signing_keys .public_key() .to_bech32() .map_err(|e| anyhow!("Failed to convert public key to bech32 npub format: {}", e))?; @@ -280,9 +320,35 @@ impl AuditClient { TagKind::custom("relays"), vec![relay_url.clone()], )) - .build(self.keys()) + .tag(Tag::custom( + TagKind::custom("maintainers"), + maintainer_pubkeys.to_vec(), + )) + .build(signing_keys) .map_err(|e| anyhow!("Failed to build repository announcement event: {}", e))?; + Ok((event, repo_id)) + } + + /// Create a NIP-34 repository announcement event with the client's maintainer + /// + /// 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). + /// The client's maintainer key is automatically added to the maintainers tag. + /// + /// # Arguments + /// * `test_name` - Name of the test (used to create unique repo identifier) + /// + /// # Returns + /// A built and signed Event ready to be sent to the relay + pub async fn create_repo_announcement(&self, test_name: &str) -> Result { + let (event, _repo_id) = self + .create_repo_announcement_custom( + test_name, + self.keys(), + &[self.maintainer_pubkey_hex()], + ) + .await?; Ok(event) } @@ -303,60 +369,9 @@ impl AuditClient { 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))?; - + let (event, _repo_id) = self + .create_repo_announcement_custom(test_name, self.keys(), maintainer_pubkeys) + .await?; Ok(event) } @@ -465,10 +480,14 @@ mod tests { fn test_event_builder() { let config = AuditConfig::ci(); let keys = Keys::generate(); + let maintainer_keys = Keys::generate(); + let recursive_maintainer_keys = Keys::generate(); let client = AuditClient { client: Client::new(keys.clone()), config: config.clone(), keys: keys.clone(), + maintainer_keys, + recursive_maintainer_keys, }; let _builder = client.event_builder(Kind::TextNote, "test content"); @@ -481,10 +500,14 @@ mod tests { fn test_audit_tags_automatically_added() { let config = AuditConfig::ci(); let keys = Keys::generate(); + let maintainer_keys = Keys::generate(); + let recursive_maintainer_keys = Keys::generate(); let client = AuditClient { client: Client::new(keys.clone()), config: config.clone(), keys: keys.clone(), + maintainer_keys, + recursive_maintainer_keys, }; // Create an event with a custom tag diff --git a/grasp-audit/src/fixtures.rs b/grasp-audit/src/fixtures.rs index 9ccd703..f7988a0 100644 --- a/grasp-audit/src/fixtures.rs +++ b/grasp-audit/src/fixtures.rs @@ -28,7 +28,7 @@ use nostr_sdk::prelude::Event; use std::collections::HashMap; use std::sync::{Arc, Mutex}; -/// Deterministic commit hash used in RepoState fixtures +/// Deterministic commit hash used in RepoState fixtures (Owner variant) /// This is the hash produced by creating a commit with: /// - Message: "Initial commit" /// - File: test.txt containing "Initial commit" @@ -39,20 +39,101 @@ use std::sync::{Arc, Mutex}; /// - Parent: Initial empty commit (09cc37de80f3434fa98864a86730b8d7777bd6ae) pub const DETERMINISTIC_COMMIT_HASH: &str = "64ea71d79a57a7acb334cd9651f8aec067c0ce5d"; +/// Deterministic commit hash for maintainer fixtures (Maintainer variant) +/// This is the hash produced by creating a commit with: +/// - Message: "Maintainer initial commit" +/// - File: test.txt containing "Maintainer initial commit" +/// - Author date: 2024-01-01T00:00:00Z +/// - Committer date: 2024-01-01T00:00:00Z +/// - GPG signing: disabled +/// - User: "GRASP Audit Test " +/// - Parent: none (root commit) +/// NOTE: This value is different from DETERMINISTIC_COMMIT_HASH due to different content +pub const MAINTAINER_DETERMINISTIC_COMMIT_HASH: &str = "1c2d472c9b71ed51968a66500281a3c4a6840464"; + +/// Deterministic commit hash for recursive maintainer fixtures (RecursiveMaintainer variant) +/// This is the hash produced by creating a commit with: +/// - Message: "Recursive maintainer initial commit" +/// - File: test.txt containing "Recursive maintainer initial commit" +/// - Author date: 2024-01-01T00:00:00Z +/// - Committer date: 2024-01-01T00:00:00Z +/// - GPG signing: disabled +/// - User: "GRASP Audit Test " +/// - Parent: none (root commit) +/// NOTE: This value is different from DETERMINISTIC_COMMIT_HASH due to different content +pub const RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH: &str = "05939b82de66fbdb9c077d0a64fc68522f3cb8e0"; + /// Types of test fixtures available +/// +/// ## Fixture Dependencies +/// +/// Several fixtures depend on `ValidRepo` - they all use the SAME repo_id +/// within a single TestContext instance to ensure proper fixture relationships: +/// - `RepoState` → uses ValidRepo's repo_id +/// - `MaintainerAnnouncement` + `MaintainerState` → uses ValidRepo's repo_id +/// - `RecursiveMaintainerRepoAndState` → uses ValidRepo's repo_id +/// +/// This enables testing recursive maintainer authorization chains where multiple +/// parties publish announcements and state events for the same repository. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum FixtureKind { /// Basic repository announcement (kind 30617) + /// - Signed by owner keys (`client.keys()`) + /// - Lists `client.maintainer_pubkey_hex()` in maintainers tag ValidRepo, /// Repository with one issue (kind 1621) + /// - Requires ValidRepo (reuses same repo_id) RepoWithIssue, /// Repository with issue and comment (kind 1111) + /// - Requires RepoWithIssue (reuses same repo_id) RepoWithComment, - /// Repository state announcement (kind 30618) + /// Repository state announcement (kind 30618) for owner + /// - Requires ValidRepo (uses same repo_id) + /// - Signed by owner keys (`client.keys()`) + /// - Points to DETERMINISTIC_COMMIT_HASH + /// - Timestamp: 10 seconds in the past RepoState, + + /// Maintainer's repo announcement only for the SAME repo_id as ValidRepo + /// - Requires ValidRepo (uses same repo_id for maintainer chain) + /// - Announcement signed by `client.maintainer_keys()` + /// - Lists `client.recursive_maintainer_pubkey_hex()` in maintainers tag + /// - Does NOT include state event (use MaintainerState for that) + MaintainerAnnouncement, + + /// Maintainer's state event only for the SAME repo_id as ValidRepo + /// - Requires ValidRepo (uses same repo_id for maintainer chain) + /// - State event signed by `client.maintainer_keys()` + /// - Points to MAINTAINER_DETERMINISTIC_COMMIT_HASH + /// - Timestamp: 5 seconds in the past (more recent than owner's state) + /// - Does NOT include announcement (use MaintainerAnnouncement for that) + MaintainerState, + + /// Recursive maintainer's announcement only for the SAME repo_id as ValidRepo + /// - Requires ValidRepo (uses same repo_id for recursive chain) + /// - Announcement signed by `client.recursive_maintainer_keys()` + /// - Lists owner and maintainer in maintainers tag + /// - Does NOT include state event (use RecursiveMaintainerState for that) + RecursiveMaintainerAnnouncement, + + /// Recursive maintainer's state event only for the SAME repo_id as ValidRepo + /// - Requires ValidRepo (uses same repo_id for recursive chain) + /// - State event signed by `client.recursive_maintainer_keys()` + /// - Points to RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH + /// - Timestamp: 2 seconds in the past (most recent) + /// - Does NOT include announcement (use RecursiveMaintainerAnnouncement for that) + RecursiveMaintainerState, + + /// Recursive maintainer's announcement + state for the SAME repo_id as ValidRepo + /// - Requires ValidRepo (uses same repo_id for recursive chain) + /// - Announcement signed by `client.recursive_maintainer_keys()` + /// - Lists owner and maintainer in maintainers tag + /// - State event points to RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH + /// - Timestamp: 2 seconds in the past (most recent) + RecursiveMaintainerRepoAndState, } /// Context mode for fixture management @@ -214,12 +295,17 @@ impl<'a> TestContext<'a> { Ok(event) } - /// Get or create a ValidRepo, with mode-appropriate caching. + /// Get or create a ValidRepo, with caching within the TestContext. /// This is a helper method that avoids async recursion by not going /// through get_fixture. It handles the repo specifically. + /// + /// IMPORTANT: We always cache within a TestContext instance to ensure + /// fixture dependencies work correctly. The isolation between tests + /// comes from each test having its own TestContext with a fresh cache. async fn get_or_create_repo(&self) -> Result { - // In Shared mode, check cache first - if self.mode == ContextMode::Shared { + // Always check cache first - this ensures fixture dependencies work + // (e.g., MaintainerRepoAndState needs the SAME repo_id as RepoState) + { let cache = self.cache.lock().unwrap(); if let Some(event) = cache.get(&FixtureKind::ValidRepo) { return Ok(event.clone()); @@ -237,8 +323,8 @@ impl<'a> TestContext<'a> { // Send it self.client.send_event(repo.clone()).await?; - // Cache it in Shared mode - if self.mode == ContextMode::Shared { + // Always cache it - isolation comes from each test having its own TestContext + { let mut cache = self.cache.lock().unwrap(); cache.insert(FixtureKind::ValidRepo, repo.clone()); } @@ -246,18 +332,18 @@ impl<'a> TestContext<'a> { Ok(repo) } - /// Get or create a RepoWithIssue, with mode-appropriate caching. + /// Get or create a RepoWithIssue, with caching within the TestContext. /// Returns the issue event (repo is already sent/cached via get_or_create_repo). async fn get_or_create_issue(&self) -> Result { - // In Shared mode, check cache first - if self.mode == ContextMode::Shared { + // Always check cache first - ensures fixture dependencies work + { let cache = self.cache.lock().unwrap(); if let Some(event) = cache.get(&FixtureKind::RepoWithIssue) { return Ok(event.clone()); } } - // Get or create repo (reuses cached in Shared mode) + // Get or create repo (reuses cached within this TestContext) let repo = self.get_or_create_repo().await?; // Create the issue @@ -271,8 +357,8 @@ impl<'a> TestContext<'a> { // Send it self.client.send_event(issue.clone()).await?; - // Cache it in Shared mode - if self.mode == ContextMode::Shared { + // Always cache it - isolation comes from each test having its own TestContext + { let mut cache = self.cache.lock().unwrap(); cache.insert(FixtureKind::RepoWithIssue, issue.clone()); } @@ -284,12 +370,8 @@ impl<'a> TestContext<'a> { async fn build_fixture(&self, kind: FixtureKind) -> Result { match kind { FixtureKind::ValidRepo => { - let test_name = format!( - "fixture-{:?}-{}", - kind, - &uuid::Uuid::new_v4().to_string()[..8] - ); - self.client.create_repo_announcement(&test_name).await + // Delegate to get_or_create_repo() which handles caching properly. + self.get_or_create_repo().await } FixtureKind::RepoWithIssue => { @@ -340,6 +422,9 @@ impl<'a> TestContext<'a> { .to_string(); // Create state announcement with deterministic commit hash + let base_time = Timestamp::now().as_u64(); + let older_timestamp = Timestamp::from(base_time - 10); // 10 seconds ago + // Tag format: ["refs/heads/main", ""] // Note: We build the state but DON'T send it here - the caller will send it self.client @@ -353,12 +438,267 @@ impl<'a> TestContext<'a> { TagKind::custom("HEAD"), vec!["ref: refs/heads/main".to_string()], )) + .custom_time(older_timestamp) .build(self.client.keys()) .map_err(|e| anyhow::anyhow!("Failed to build state announcement: {}", e)) } + + FixtureKind::MaintainerAnnouncement => { + use nostr_sdk::prelude::*; + + // Get the owner's repo to use the SAME repo_id + let owner_repo = self.get_or_create_repo().await?; + + // Extract repo_id from owner's repo announcement + let repo_id = owner_repo + .tags + .iter() + .find(|t| t.kind() == TagKind::d()) + .and_then(|t| t.content()) + .ok_or_else(|| anyhow::anyhow!("Missing d tag in owner repo announcement"))? + .to_string(); + + self.build_maintainer_announcement(&repo_id).await + } + + FixtureKind::MaintainerState => { + use nostr_sdk::prelude::*; + + // Get the owner's repo to use the SAME repo_id + let owner_repo = self.get_or_create_repo().await?; + + // Extract repo_id from owner's repo announcement + let repo_id = owner_repo + .tags + .iter() + .find(|t| t.kind() == TagKind::d()) + .and_then(|t| t.content()) + .ok_or_else(|| anyhow::anyhow!("Missing d tag in owner repo announcement"))? + .to_string(); + + // Build state event ONLY - does NOT send announcement + // This allows testing state-only scenarios + self.build_maintainer_state(&repo_id) + } + + FixtureKind::RecursiveMaintainerAnnouncement => { + use nostr_sdk::prelude::*; + + // Get the owner's repo to use the SAME repo_id + let owner_repo = self.get_or_create_repo().await?; + + // Extract repo_id from owner's repo announcement + let repo_id = owner_repo + .tags + .iter() + .find(|t| t.kind() == TagKind::d()) + .and_then(|t| t.content()) + .ok_or_else(|| anyhow::anyhow!("Missing d tag in owner repo announcement"))? + .to_string(); + + self.build_recursive_maintainer_announcement(&repo_id).await + } + + FixtureKind::RecursiveMaintainerState => { + use nostr_sdk::prelude::*; + + // Get the owner's repo to use the SAME repo_id + let owner_repo = self.get_or_create_repo().await?; + + // Extract repo_id from owner's repo announcement + let repo_id = owner_repo + .tags + .iter() + .find(|t| t.kind() == TagKind::d()) + .and_then(|t| t.content()) + .ok_or_else(|| anyhow::anyhow!("Missing d tag in owner repo announcement"))? + .to_string(); + + // Build state event ONLY - does NOT send announcement + self.build_recursive_maintainer_state(&repo_id) + } + + FixtureKind::RecursiveMaintainerRepoAndState => { + use nostr_sdk::prelude::*; + + // Get the owner's repo to use the SAME repo_id + let owner_repo = self.get_or_create_repo().await?; + + // Extract repo_id from owner's repo announcement + let repo_id = owner_repo + .tags + .iter() + .find(|t| t.kind() == TagKind::d()) + .and_then(|t| t.content()) + .ok_or_else(|| anyhow::anyhow!("Missing d tag in owner repo announcement"))? + .to_string(); + + // Build and send the recursive maintainer's repo announcement + let recursive_maintainer_announcement = self.build_recursive_maintainer_announcement(&repo_id).await?; + self.client.send_event(recursive_maintainer_announcement).await?; + + // Return the state event (caller will send it) + self.build_recursive_maintainer_state(&repo_id) + } } } + /// Build maintainer announcement event for the given repo_id + async fn build_maintainer_announcement(&self, repo_id: &str) -> Result { + use nostr_sdk::prelude::*; + + // Get relay URL for clone tag + let relay_url = self.client + .client() + .relays() + .await + .keys() + .next() + .ok_or_else(|| anyhow::anyhow!("No relay connected"))? + .to_string(); + let http_url = relay_url + .replace("ws://", "http://") + .replace("wss://", "https://"); + + // Create maintainer's repo announcement for the SAME repo_id + let maintainer_npub = self.client + .maintainer_keys() + .public_key() + .to_bech32() + .map_err(|e| anyhow::anyhow!("Failed to convert maintainer pubkey: {}", e))?; + + self.client + .event_builder( + Kind::GitRepoAnnouncement, + format!("Maintainer announcement for {}", repo_id), + ) + .tag(Tag::identifier(repo_id)) + .tag(Tag::custom( + TagKind::custom("name"), + vec![format!("{} (maintainer)", repo_id)], + )) + .tag(Tag::custom( + TagKind::custom("clone"), + vec![format!("{}/{}/{}.git", http_url, maintainer_npub, repo_id)], + )) + .tag(Tag::custom( + TagKind::custom("relays"), + vec![relay_url], + )) + .tag(Tag::custom( + TagKind::custom("maintainers"), + vec![self.client.recursive_maintainer_pubkey_hex()], + )) + .build(self.client.maintainer_keys()) + .map_err(|e| anyhow::anyhow!("Failed to build maintainer repo announcement: {}", e)) + } + + /// Build maintainer state event for the given repo_id + fn build_maintainer_state(&self, repo_id: &str) -> Result { + use nostr_sdk::prelude::*; + + // Create state announcement 5 seconds in the past, signed by maintainer + let base_time = Timestamp::now().as_u64(); + let older_timestamp = Timestamp::from(base_time - 5); // 5 seconds ago + + self.client + .event_builder(Kind::Custom(30618), "") + .tag(Tag::identifier(repo_id)) + .tag(Tag::custom( + TagKind::custom("refs/heads/main"), + vec![MAINTAINER_DETERMINISTIC_COMMIT_HASH.to_string()], + )) + .tag(Tag::custom( + TagKind::custom("HEAD"), + vec!["ref: refs/heads/main".to_string()], + )) + .custom_time(older_timestamp) + .build(self.client.maintainer_keys()) + .map_err(|e| anyhow::anyhow!("Failed to build maintainer state announcement: {}", e)) + } + + /// Build recursive maintainer announcement event for the given repo_id + async fn build_recursive_maintainer_announcement(&self, repo_id: &str) -> Result { + use nostr_sdk::prelude::*; + + // Get relay URL for clone tag + let relay_url = self.client + .client() + .relays() + .await + .keys() + .next() + .ok_or_else(|| anyhow::anyhow!("No relay connected"))? + .to_string(); + let http_url = relay_url + .replace("ws://", "http://") + .replace("wss://", "https://"); + + // Create recursive maintainer's repo announcement for the SAME repo_id + let recursive_maintainer_npub = self.client + .recursive_maintainer_keys() + .public_key() + .to_bech32() + .map_err(|e| anyhow::anyhow!("Failed to convert recursive maintainer pubkey: {}", e))?; + + self.client + .event_builder( + Kind::GitRepoAnnouncement, + format!("Recursive maintainer announcement for {}", repo_id), + ) + .tag(Tag::identifier(repo_id)) + .tag(Tag::custom( + TagKind::custom("name"), + vec![format!("{} (recursive maintainer)", repo_id)], + )) + .tag(Tag::custom( + TagKind::custom("clone"), + vec![format!("{}/{}/{}.git", http_url, recursive_maintainer_npub, repo_id)], + )) + .tag(Tag::custom( + TagKind::custom("relays"), + vec![relay_url], + )) + .tag(Tag::custom( + TagKind::custom("maintainers"), + vec![ + self.client.public_key().to_hex(), + self.client.maintainer_pubkey_hex(), + ], + )) + .build(self.client.recursive_maintainer_keys()) + .map_err(|e| anyhow::anyhow!("Failed to build recursive maintainer repo announcement: {}", e)) + } + + /// Build recursive maintainer state event for the given repo_id + fn build_recursive_maintainer_state(&self, repo_id: &str) -> Result { + use nostr_sdk::prelude::*; + + // Create state announcement 2 seconds in the past, signed by recursive maintainer + let base_time = Timestamp::now().as_u64(); + let older_timestamp = Timestamp::from(base_time - 2); // 2 seconds ago + + self.client + .event_builder(Kind::Custom(30618), "") + .tag(Tag::identifier(repo_id)) + .tag(Tag::custom( + TagKind::custom("refs/heads/main"), + vec![RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH.to_string()], + )) + .tag(Tag::custom( + TagKind::custom("HEAD"), + vec!["ref: refs/heads/main".to_string()], + )) + .custom_time(older_timestamp) + .build(self.client.recursive_maintainer_keys()) + .map_err(|e| { + anyhow::anyhow!( + "Failed to build recursive maintainer state announcement: {}", + e + ) + }) + } + /// Clear the fixture cache /// /// This is useful for tests that want to ensure fresh fixtures diff --git a/grasp-audit/src/lib.rs b/grasp-audit/src/lib.rs index deed2d5..b7ce992 100644 --- a/grasp-audit/src/lib.rs +++ b/grasp-audit/src/lib.rs @@ -37,7 +37,10 @@ pub mod specs; pub use audit::{AuditConfig, AuditMode}; pub use client::AuditClient; -pub use fixtures::{ContextMode, FixtureKind, TestContext, DETERMINISTIC_COMMIT_HASH}; +pub use fixtures::{ + ContextMode, FixtureKind, TestContext, DETERMINISTIC_COMMIT_HASH, + MAINTAINER_DETERMINISTIC_COMMIT_HASH, RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH, +}; pub use result::{AuditResult, TestResult}; // Re-export commonly used types diff --git a/grasp-audit/src/specs/grasp01/git_clone.rs b/grasp-audit/src/specs/grasp01/git_clone.rs index f85f94a..da60f26 100644 --- a/grasp-audit/src/specs/grasp01/git_clone.rs +++ b/grasp-audit/src/specs/grasp01/git_clone.rs @@ -267,7 +267,7 @@ impl GitCloneTests { #[cfg(test)] mod tests { - use super::*; + #[test] fn test_module_exists() { diff --git a/grasp-audit/src/specs/grasp01/mod.rs b/grasp-audit/src/specs/grasp01/mod.rs index 0d0bd9c..5ce5eca 100644 --- a/grasp-audit/src/specs/grasp01/mod.rs +++ b/grasp-audit/src/specs/grasp01/mod.rs @@ -13,5 +13,5 @@ pub use event_acceptance_policy::EventAcceptancePolicyTests; pub use git_clone::GitCloneTests; pub use nip01_smoke::Nip01SmokeTests; pub use nip11_document::Nip11DocumentTests; -pub use push_authorization::PushAuthorizationTests; +pub use push_authorization::{CommitVariant, PushAuthorizationTests}; pub use repository_creation::RepositoryCreationTests; diff --git a/grasp-audit/src/specs/grasp01/push_authorization.rs b/grasp-audit/src/specs/grasp01/push_authorization.rs index 5545b1a..cba9e69 100644 --- a/grasp-audit/src/specs/grasp01/push_authorization.rs +++ b/grasp-audit/src/specs/grasp01/push_authorization.rs @@ -100,11 +100,51 @@ fn create_commit(clone_path: &Path, message: &str) -> Result { 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 -pub fn create_deterministic_commit(clone_path: &Path, message: &str) -> Result { +/// +/// 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"); - fs::write(&test_file, message).map_err(|e| format!("Failed to write file: {}", e))?; + 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"]) @@ -147,6 +187,14 @@ pub fn create_deterministic_commit(clone_path: &Path, message: &str) -> Result 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 { @@ -286,6 +334,282 @@ pub async fn setup_repo_with_deterministic_commit( }) } +/// 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") @@ -426,908 +750,133 @@ impl PushAuthorizationTests { } } - /// Test that latest state event is used for authorization + /// Test push authorized by maintainer state event only (no announcement) /// - /// 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. + /// 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 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( + /// 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_latest_state_event_used"; - let description = "Latest state event takes precedence"; + 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)) + } + } + } + } - // 1. Generate maintainer keypair - let maintainer_keys = Keys::generate(); - let maintainer_pubkey = maintainer_keys.public_key().to_hex(); + /// 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"; - // 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)) + // 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)) + } } - }; - - // 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" - /// 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_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)); - } - }; - - 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 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_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(); - - // 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 /// @@ -1413,279 +962,6 @@ 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