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.
This commit is contained in:
DanConwayDev
2026-02-13 09:24:51 +00:00
parent faac6027de
commit f4e8e1089a
3 changed files with 344 additions and 58 deletions
+242
View File
@@ -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 <test@grasp-audit.local>"
/// - 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/<pr-event-id>
///
/// - 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-id>
/// - PR event should be released from purgatory
///
/// - Requires PREvent2Sent
/// - Pushes correct commit (PR_TEST_COMMIT_HASH_2) to refs/nostr/<pr-event-id>
/// - 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<Event> {
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<Event> {
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/<pr-event-id> after event was sent.
async fn build_pr_event_2_git_data_pushed(&self) -> Result<Event> {
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<Event> {
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",
}
}
}
+96 -52
View File
@@ -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/<pr-event-id>
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)
.await
.map_err(|e| format!("Failed to build PR event: {}", e))?;
// Send PR event
let (_, in_purgatory) = client
.send_event_and_note_purgatory(pr_event.clone())
.get_fixture(FixtureKind::PREvent2Sent)
.await
.map_err(|e| format!("Failed to send PR event: {}", e))?;
if !in_purgatory {
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 query PR events: {}", e))?;
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
/// Test: PR event remains in purgatory until git data arrives
///
/// Spec: GRASP-01 Line 22
/// "...kept in purgatory (not served) until the related git data arrives"
///
/// This test verifies:
/// 1. Send PR event (enters purgatory)
/// 2. Push git data to refs/nostr/<pr-event-id> 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)
.await
.map_err(|e| format!("Failed to get existing repo: {}", e))?;
// Build PR event
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 get PR event: {}", e))?;
// Send PR event (should enter purgatory)
let (_, _in_purgatory) = client
.send_event_and_note_purgatory(pr_event.clone())
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 send PR event: {}", e))?;
.map_err(|e| format!("Failed to query PR events: {}", e))?;
// TODO: Push git data to refs/nostr/<pr-event-id>
// This requires git operations similar to OwnerStateDataPushed
if !events.is_empty() {
return Err(format!(
"PR event was served without git data - purgatory not working. Event ID: {}",
pr_event.id
));
}
Ok(())
})
.await
}
/// Test: Git push accepted for PR event in purgatory
///
/// Verifies that pushing the correct commit to refs/nostr/<pr-event-id>
/// 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 push git data for PR event: {}", e))?;
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
));
}
+6 -6
View File
@@ -61,8 +61,8 @@ macro_rules! isolated_purgatory_test {
// Announcement Purgatory Tests (commented out - feature not yet implemented)
// ============================================================
isolated_purgatory_test!(test_announcement_not_served_before_git_data);
// isolated_purgatory_test!(test_announcement_served_after_git_push);
// isolated_purgatory_test!(test_announcement_not_served_before_git_data);
isolated_purgatory_test!(test_announcement_served_after_git_push);
isolated_purgatory_test!(test_bare_repo_exists_for_purgatory_announcement);
isolated_purgatory_test!(test_state_event_accepted_for_purgatory_announcement);
@@ -77,7 +77,7 @@ isolated_purgatory_test!(test_state_event_served_after_git_push);
// PR Purgatory Tests
// ============================================================
isolated_purgatory_test!(test_pr_event_not_served_before_git_data);
// isolated_purgatory_test!(test_pr_event_served_after_correct_push);
// TODO: Test incomplete - needs to push git data to refs/nostr/<pr-event-id>
// See push_authorization.rs:test_push_correct_commit_to_pr_ref_after_event for proper implementation
isolated_purgatory_test!(test_pr_event_before_git_data_accepted_into_purgatory);
isolated_purgatory_test!(test_pr_event_remains_in_purgatory_until_git_data);
isolated_purgatory_test!(test_pr_event_git_push_accepted);
isolated_purgatory_test!(test_pr_event_served_after_git_push);