From f4e8e1089ae6e8e78c3576246d9747bb585fdc18 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Fri, 13 Feb 2026 09:24:51 +0000 Subject: test: add PR purgatory tests with PREvent2 fixtures Add new fixtures for testing PR purgatory mechanism: - PREvent2Generated: PR event with different commit hash - PREvent2Sent: PR event sent to relay (enters purgatory) - PREvent2GitDataPushed: Git data pushed after event sent - PREvent2Served: Full fixture with event served Add PRTestCommit2 variant for second PR test commit. Update purgatory tests to use new fixtures for proper PR purgatory testing. --- grasp-audit/src/fixtures.rs | 242 +++++++++++++++++++++++++++++ grasp-audit/src/specs/grasp01/purgatory.rs | 144 +++++++++++------ 2 files changed, 336 insertions(+), 50 deletions(-) (limited to 'grasp-audit') diff --git a/grasp-audit/src/fixtures.rs b/grasp-audit/src/fixtures.rs index 56d29ef..8a51d77 100644 --- a/grasp-audit/src/fixtures.rs +++ b/grasp-audit/src/fixtures.rs @@ -103,6 +103,17 @@ pub const RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH: &str = /// - Parent: none (root commit) pub const PR_TEST_COMMIT_HASH: &str = "5a51b30e4615b572dcd5b9e487861b58605a5c21"; +/// Deterministic commit hash for second PR test fixtures (PRTestCommit2 variant) +/// This is the hash produced by creating a commit with: +/// - Message: "PR test deterministic commit 2" +/// - File: test.txt containing "PR test deterministic commit 2\n" (with trailing newline) +/// - 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) +pub const PR_TEST_COMMIT_HASH_2: &str = "99420bc57835f5bc8ca20ab21a8d12850043920e"; + /// Types of test fixtures available /// /// ## Fixture Dependencies @@ -216,6 +227,50 @@ pub enum FixtureKind { /// - Returns: the sent PR event PREventSentAfterWrongPush, + /// Second PR event generated (built) but NOT sent to relay + /// + /// Uses PR_TEST_COMMIT_HASH_2 (different from PR_TEST_COMMIT_HASH). + /// This allows testing purgatory mechanism with a separate PR event + /// that doesn't conflict with existing PR fixtures. + /// + /// - Requires ValidRepoServed (uses same repo_id, needs git data to exist) + /// - Signed by `client.pr_author_keys()` + /// - Kind 1618 (NIP-34 PR) + /// - Includes `c` tag pointing to PR_TEST_COMMIT_HASH_2 + /// - NOT sent to relay + PREvent2Generated, + + /// Second PR event sent to relay (enters purgatory) + /// + /// After this fixture: + /// - PR event is on relay but NOT served (in purgatory) + /// - No git data at refs/nostr/ + /// + /// - Requires PREvent2Generated + /// - Sends the PR event to relay + /// - Returns: the sent PR event (in purgatory) + PREvent2Sent, + + /// Git data pushed for second PR event AFTER event was sent + /// + /// After this fixture: + /// - PR event was in purgatory + /// - Correct commit pushed to refs/nostr/ + /// - PR event should be released from purgatory + /// + /// - Requires PREvent2Sent + /// - Pushes correct commit (PR_TEST_COMMIT_HASH_2) to refs/nostr/ + /// - Returns: the PR event (should now be served) + PREvent2GitDataPushed, + + /// Full fixture: second PR event sent, git pushed, event served + /// + /// Combines PREvent2Sent + PREvent2GitDataPushed for convenience. + /// + /// - Requires PREvent2GitDataPushed + /// - Returns: the served PR event + PREvent2Served, + /// Owner's state event with git data successfully pushed (full 4-stage fixture) /// /// This fixture represents the complete flow for testing state push authorization: @@ -293,6 +348,12 @@ impl FixtureKind { Self::PRWrongCommitPushedBeforeEvent => vec![Self::PREventGenerated], Self::PREventSentAfterWrongPush => vec![Self::PRWrongCommitPushedBeforeEvent], + // Second PR event fixtures (for purgatory testing) + Self::PREvent2Generated => vec![Self::ValidRepoServed], + Self::PREvent2Sent => vec![Self::PREvent2Generated], + Self::PREvent2GitDataPushed => vec![Self::PREvent2Sent], + Self::PREvent2Served => vec![Self::PREvent2GitDataPushed], + Self::OwnerStateDataPushed => vec![Self::ValidRepoSent], // Fixtures that depend on RepoWithIssue @@ -329,6 +390,11 @@ impl FixtureKind { Self::PRWrongCommitPushedBeforeEvent => true, // PREventSentAfterWrongPush sends the PR event internally Self::PREventSentAfterWrongPush => true, + // Second PR event fixtures handle their own events/git data + Self::PREvent2Generated => true, + Self::PREvent2Sent => true, + Self::PREvent2GitDataPushed => true, + Self::PREvent2Served => true, // HeadSetToDevelopBranch sends its state event internally Self::HeadSetToDevelopBranch => true, // ValidRepoServed doesn't send anything itself, just returns cached event @@ -800,6 +866,11 @@ impl<'a> TestContext<'a> { self.build_pr_event_sent_after_wrong_push().await } + FixtureKind::PREvent2Generated => self.build_pr_event_2_generated().await, + FixtureKind::PREvent2Sent => self.build_pr_event_2_sent().await, + FixtureKind::PREvent2GitDataPushed => self.build_pr_event_2_git_data_pushed().await, + FixtureKind::PREvent2Served => self.build_pr_event_2_served().await, + FixtureKind::OwnerStateDataPushed => self.build_owner_state_data_pushed().await, FixtureKind::MaintainerStateDataPushed => { @@ -1561,6 +1632,173 @@ impl<'a> TestContext<'a> { Ok(pr_event) } + /// Build PREvent2Generated fixture + /// + /// Creates a PR event with `c` tag pointing to PR_TEST_COMMIT_HASH_2. + /// The event is NOT sent to the relay. + async fn build_pr_event_2_generated(&self) -> Result { + use nostr_sdk::prelude::*; + + let repo = self.get_cached_dependency(FixtureKind::ValidRepoServed)?; + let repo_id = self.extract_repo_id(&repo)?; + + let base_time = Timestamp::now().as_secs(); + let pr_timestamp = Timestamp::from(base_time - 1); + + self.client + .event_builder(Kind::GitPullRequest, "Test PR 2 for GRASP validation") + .tag(Tag::custom( + TagKind::custom("a"), + vec![format!( + "30617:{}:{}", + self.client.public_key().to_hex(), + repo_id + )], + )) + .tag(Tag::custom( + TagKind::custom("c"), + vec![PR_TEST_COMMIT_HASH_2.to_string()], + )) + .custom_time(pr_timestamp) + .build(self.client.pr_author_keys()) + .map_err(|e| anyhow::anyhow!("Failed to build PR event 2: {}", e)) + } + + /// Build PREvent2Sent fixture + /// + /// Sends the PR event to relay. Event should enter purgatory. + async fn build_pr_event_2_sent(&self) -> Result { + let pr_event = self.get_cached_dependency(FixtureKind::PREvent2Generated)?; + + let (_, in_purgatory) = self + .client + .send_event_and_note_purgatory(pr_event.clone()) + .await?; + + if !in_purgatory { + return Err(anyhow::anyhow!( + "PR event 2 was served immediately - purgatory not implemented" + )); + } + + Ok(pr_event) + } + + /// Build PREvent2GitDataPushed fixture + /// + /// Pushes correct commit to refs/nostr/ after event was sent. + async fn build_pr_event_2_git_data_pushed(&self) -> Result { + use nostr_sdk::prelude::*; + + let pr_event = self.get_cached_dependency(FixtureKind::PREvent2Sent)?; + let pr_event_id = pr_event.id.to_hex(); + + let repo = self.get_cached_dependency(FixtureKind::ValidRepoServed)?; + let repo_id = self.extract_repo_id(&repo)?; + + let relay_domain = self.get_relay_domain().await?; + + let npub = repo + .pubkey + .to_bech32() + .map_err(|e| anyhow::anyhow!("Failed to convert pubkey: {}", e))?; + + let clone_path = clone_repo(&relay_domain, &npub, &repo_id) + .map_err(|e| anyhow::anyhow!("Failed to clone repo: {}", e))?; + + let cleanup = |path: &PathBuf| { + let _ = fs::remove_dir_all(path); + }; + + // Reset to orphan state and create deterministic root commit + // Step 1: Create orphan branch (removes all history) + let _ = Command::new("git") + .args(["checkout", "--orphan", "pr-branch"]) + .current_dir(&clone_path) + .output(); + + // Step 2: Clear staged files (orphan keeps files staged from previous branch) + let _ = Command::new("git") + .args(["rm", "-rf", "--cached", "."]) + .current_dir(&clone_path) + .output(); + + // Step 3: Remove all working directory files for clean state (except .git) + for entry in + fs::read_dir(&clone_path).map_err(|e| anyhow::anyhow!("Failed to read dir: {}", e))? + { + if let Ok(entry) = entry { + let path = entry.path(); + if path.file_name() != Some(std::ffi::OsStr::new(".git")) { + let _ = fs::remove_file(&path).or_else(|_| fs::remove_dir_all(&path)); + } + } + } + + let commit_hash = match create_deterministic_commit_with_variant( + &clone_path, + CommitVariant::PRTestCommit2, + ) { + Ok(h) => h, + Err(e) => { + cleanup(&clone_path); + return Err(anyhow::anyhow!("Failed to create PR test commit 2: {}", e)); + } + }; + + if commit_hash != PR_TEST_COMMIT_HASH_2 { + cleanup(&clone_path); + return Err(anyhow::anyhow!( + "PR test commit 2 hash mismatch: got {}, expected {}", + commit_hash, + PR_TEST_COMMIT_HASH_2 + )); + } + + let push_output = Command::new("git") + .args([ + "push", + "origin", + &format!("pr-branch:refs/nostr/{}", pr_event_id), + ]) + .current_dir(&clone_path) + .output() + .map_err(|e| { + cleanup(&clone_path); + anyhow::anyhow!("Failed to execute git push: {}", e) + })?; + + cleanup(&clone_path); + + if !push_output.status.success() { + let stderr = String::from_utf8_lossy(&push_output.stderr); + return Err(anyhow::anyhow!( + "Push to refs/nostr/{} failed: {}", + pr_event_id, + stderr + )); + } + + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + + Ok(pr_event) + } + + /// Build PREvent2Served fixture + /// + /// Full fixture: event sent, git pushed, event now served. + async fn build_pr_event_2_served(&self) -> Result { + let pr_event = self.get_cached_dependency(FixtureKind::PREvent2GitDataPushed)?; + + if !self.client.is_event_on_relay(pr_event.id).await? { + return Err(anyhow::anyhow!( + "PR event 2 not released from purgatory after git push" + )); + } + + Ok(pr_event) + } + /// Get relay domain (host:port) from the connected relay /// /// Extracts the domain from the relay URL for git HTTP operations. @@ -1867,6 +2105,8 @@ pub enum CommitVariant { RecursiveMaintainer, /// PR test commit variant - for PR event tests PRTestCommit, + /// Second PR test commit variant - for second PR event tests + PRTestCommit2, } impl CommitVariant { @@ -1877,6 +2117,7 @@ impl CommitVariant { CommitVariant::Maintainer => "Maintainer initial commit\n", CommitVariant::RecursiveMaintainer => "Recursive maintainer initial commit\n", CommitVariant::PRTestCommit => "PR test deterministic commit\n", + CommitVariant::PRTestCommit2 => "PR test deterministic commit 2\n", } } @@ -1887,6 +2128,7 @@ impl CommitVariant { CommitVariant::Maintainer => "Maintainer initial commit", CommitVariant::RecursiveMaintainer => "Recursive maintainer initial commit", CommitVariant::PRTestCommit => "PR test deterministic commit", + CommitVariant::PRTestCommit2 => "PR test deterministic commit 2", } } } diff --git a/grasp-audit/src/specs/grasp01/purgatory.rs b/grasp-audit/src/specs/grasp01/purgatory.rs index 60b6096..27ab97b 100644 --- a/grasp-audit/src/specs/grasp01/purgatory.rs +++ b/grasp-audit/src/specs/grasp01/purgatory.rs @@ -49,9 +49,11 @@ impl PurgatoryTests { results.add(Self::test_state_event_not_served_before_git_data(client).await); results.add(Self::test_state_event_served_after_git_push(client).await); - // PR purgatory tests (feature not yet implemented) - results.add(Self::test_pr_event_not_served_before_git_data(client).await); - results.add(Self::test_pr_event_served_after_correct_push(client).await); + // PR purgatory tests + results.add(Self::test_pr_event_before_git_data_accepted_into_purgatory(client).await); + results.add(Self::test_pr_event_remains_in_purgatory_until_git_data(client).await); + results.add(Self::test_pr_event_git_push_accepted(client).await); + results.add(Self::test_pr_event_served_after_git_push(client).await); results } @@ -515,37 +517,37 @@ impl PurgatoryTests { /// 1. Send PR event for a repo /// 2. PR event is NOT queryable (in purgatory) /// 3. No git data exists at refs/nostr/ - pub async fn test_pr_event_not_served_before_git_data(client: &AuditClient) -> TestResult { + pub async fn test_pr_event_before_git_data_accepted_into_purgatory( + client: &AuditClient, + ) -> TestResult { TestResult::new( - "pr_event_not_served_before_git_data", + "pr_event_before_git_data_accepted_into_purgatory", SpecRef::PurgatoryAcceptUntilGitData, - "PR events SHOULD be accepted but not served until git data arrives", + "PR event SHOULD be accepted into purgatory when git data doesn't exist", ) .run(|| async { let ctx = TestContext::new(client); - // Get a repo announcement - let _repo = ctx - .get_fixture(FixtureKind::ValidRepoSent) - .await - .map_err(|e| format!("Failed to create repo: {}", e))?; - - // Build PR event (not sent yet) let pr_event = ctx - .build_fixture_only(FixtureKind::PREvent) + .get_fixture(FixtureKind::PREvent2Sent) .await - .map_err(|e| format!("Failed to build PR event: {}", e))?; + .map_err(|e| format!("Failed to send PR event: {}", e))?; - // Send PR event - let (_, in_purgatory) = client - .send_event_and_note_purgatory(pr_event.clone()) + let filter = Filter::new() + .kind(Kind::GitPullRequest) + .author(client.pr_author_keys().public_key()) + .id(pr_event.id); + + tokio::time::sleep(Duration::from_millis(300)).await; + + let events = client + .query(filter) .await - .map_err(|e| format!("Failed to send PR event: {}", e))?; + .map_err(|e| format!("Failed to query PR events: {}", e))?; - if !in_purgatory { + if !events.is_empty() { return Err(format!( - "PR event was served immediately - purgatory not implemented. \ - Event ID: {} should NOT be queryable until git data arrives", + "PR event was served immediately - should be in purgatory. Event ID: {}", pr_event.id )); } @@ -555,46 +557,89 @@ impl PurgatoryTests { .await } - /// Test: PR event served after correct push - /// - /// Spec: GRASP-01 Line 22 - /// "...kept in purgatory (not served) until the related git data arrives" + /// Test: PR event remains in purgatory until git data arrives /// - /// This test verifies: - /// 1. Send PR event (enters purgatory) - /// 2. Push git data to refs/nostr/ with correct commit - /// 3. PR event is now served - pub async fn test_pr_event_served_after_correct_push(client: &AuditClient) -> TestResult { + /// Verifies the event stays in purgatory until matching git data is pushed. + pub async fn test_pr_event_remains_in_purgatory_until_git_data( + client: &AuditClient, + ) -> TestResult { TestResult::new( - "pr_event_served_after_correct_push", + "pr_event_remains_in_purgatory_until_git_data", SpecRef::PurgatoryAcceptUntilGitData, - "PR events SHOULD be served after matching git data arrives", + "PR event SHOULD remain in purgatory until git data arrives", ) .run(|| async { let ctx = TestContext::new(client); - // Get a repo with git data - let _existing_state = ctx - .get_fixture(FixtureKind::OwnerStateDataPushed) + let pr_event = ctx + .get_fixture(FixtureKind::PREvent2Sent) .await - .map_err(|e| format!("Failed to get existing repo: {}", e))?; + .map_err(|e| format!("Failed to get PR event: {}", e))?; - // Build PR event - let pr_event = ctx - .build_fixture_only(FixtureKind::PREvent) + tokio::time::sleep(Duration::from_millis(500)).await; + + let filter = Filter::new() + .kind(Kind::GitPullRequest) + .author(client.pr_author_keys().public_key()) + .id(pr_event.id); + + let events = client + .query(filter) .await - .map_err(|e| format!("Failed to build PR event: {}", e))?; + .map_err(|e| format!("Failed to query PR events: {}", e))?; + + if !events.is_empty() { + return Err(format!( + "PR event was served without git data - purgatory not working. Event ID: {}", + pr_event.id + )); + } + + Ok(()) + }) + .await + } - // Send PR event (should enter purgatory) - let (_, _in_purgatory) = client - .send_event_and_note_purgatory(pr_event.clone()) + /// Test: Git push accepted for PR event in purgatory + /// + /// Verifies that pushing the correct commit to refs/nostr/ + /// is accepted. + pub async fn test_pr_event_git_push_accepted(client: &AuditClient) -> TestResult { + TestResult::new( + "pr_event_git_push_accepted", + SpecRef::PurgatoryAcceptUntilGitData, + "Git push for PR event SHOULD be accepted", + ) + .run(|| async { + let ctx = TestContext::new(client); + + let _pr_event = ctx + .get_fixture(FixtureKind::PREvent2GitDataPushed) .await - .map_err(|e| format!("Failed to send PR event: {}", e))?; + .map_err(|e| format!("Failed to push git data for PR event: {}", e))?; - // TODO: Push git data to refs/nostr/ - // This requires git operations similar to OwnerStateDataPushed + Ok(()) + }) + .await + } + + /// Test: PR event served after git push + /// + /// Verifies the full purgatory release mechanism. + pub async fn test_pr_event_served_after_git_push(client: &AuditClient) -> TestResult { + TestResult::new( + "pr_event_served_after_git_push", + SpecRef::PurgatoryAcceptUntilGitData, + "PR event SHOULD be served after matching git data arrives", + ) + .run(|| async { + let ctx = TestContext::new(client); + + let pr_event = ctx + .get_fixture(FixtureKind::PREvent2Served) + .await + .map_err(|e| format!("Failed to complete purgatory release: {}", e))?; - // For now, verify the PR event exists let filter = Filter::new() .kind(Kind::GitPullRequest) .author(client.pr_author_keys().public_key()) @@ -607,8 +652,7 @@ impl PurgatoryTests { if events.is_empty() { return Err(format!( - "PR event not served after git push - purgatory release not implemented. \ - Event ID: {} should be queryable after git data arrives", + "PR event not served after git push. Event ID: {} should be queryable", pr_event.id )); } -- cgit v1.2.3