Files
ngit-grasp/grasp-audit/src/fixtures.rs
T
DanConwayDev 5d02ad6b89 refactor: replace hardcoded Kind constants with rust-nostr variants
- Replace KIND_REPOSITORY_ANNOUNCEMENT with Kind::GitRepoAnnouncement
- Replace KIND_REPOSITORY_STATE with Kind::RepoState
- Replace KIND_PR with Kind::GitPullRequest
- Replace KIND_PR_UPDATE with Kind::GitPullRequestUpdate
- Replace KIND_USER_GRASP_LIST with Kind::GitUserGraspList
- Replace KIND_PATCH with Kind::GitPatch
- Replace KIND_ISSUE with Kind::GitIssue
- Replace KIND_COMMENT with Kind::Comment
- Replace all Kind::Custom(30617|30618|1617|1618|1619|1621|1111|10317) patterns
- Remove all hardcoded KIND_* constants from events.rs
- Update all match statements to use Kind enum directly
- Update all filter builders to use Kind variants
- Update all test helpers and assertions

Benefits:
- Type safety: compiler prevents wrong kind numbers
- Readability: Kind::GitRepoAnnouncement is self-documenting
- Maintainability: single source of truth (rust-nostr)
- IDE support: full autocompletion and refactoring
- Standards: aligns with rust-nostr best practices

Files modified: 21
Constants removed: 9
Patterns replaced: 100+
Tests passing: 222/222
2026-01-08 11:23:28 +00:00

2062 lines
80 KiB
Rust

//! Test fixture management for dual-mode testing
//!
//! This module provides a TestContext abstraction that manages prerequisite events
//! differently based on the audit mode:
//!
//! - **Isolated Mode**: Creates fresh events for each test, ensuring complete isolation.
//! Use this for `cargo test` where tests run in parallel and need isolation.
//! - **Shared Mode**: Reuses shared fixtures across tests to minimize event publication.
//! Use this for CLI audit where tests run sequentially and build on each other.
//!
//! # Cache Sharing Strategy
//!
//! The caching behavior depends on the mode:
//!
//! - **Shared mode** (default for CLI): Uses the client's fixture cache, shared across
//! all TestContext instances. Fixtures are created once and reused.
//! - **Isolated mode**: Each TestContext has its own local cache. Fixtures are created
//! fresh for each TestContext, providing complete test isolation.
//!
//! # When to Use Each Mode
//!
//! - **CLI audit tool**: Use Shared mode (default). Tests run sequentially and fixtures
//! build on each other efficiently.
//! - **cargo test**: Use Isolated mode. Tests run in parallel and need complete isolation.
//!
//! # What is a Fixture?
//! A fixture represents the state of a repository on a grasp server (events and git refs)
//! and/or nostr events to be sent to the server to change this state.
//!
//! Nearly all fixures include dependant fixtures so tests dont need to call every parent fixture.
//!
//! As entire tests are often fixtures to be built on by other fixtures / tests, some tests just take
//! the fixture Result and wrap it in pass fail using the error message.
//!
//! # Out of Scope
//!
//! local repo's used in tests are always cloned fresh and never part of a fixture
//!
//! # Example
//!
//! ```no_run
//! use grasp_audit::*;
//!
//! # async fn example() -> anyhow::Result<()> {
//! let config = AuditConfig::shared(); // Use shared() for CLI, isolated() for cargo test
//! let client = AuditClient::new("ws://localhost:7000", config).await?;
//! let ctx = TestContext::new(&client);
//!
//! // Request a fixture - behavior depends on mode
//! let repo = ctx.get_fixture(FixtureKind::ValidRepo).await?;
//! # Ok(())
//! # }
//! ```
use crate::{AuditClient, AuditMode};
use anyhow::{Context, Result};
use nostr_sdk::prelude::Event;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
/// 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"
/// - 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: 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 <test@grasp-audit.local>"
/// - 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 <test@grasp-audit.local>"
/// - 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";
/// Deterministic commit hash for PR test fixtures (PRTestCommit variant)
/// This is the hash produced by creating a commit with:
/// - Message: "PR test deterministic commit"
/// - File: test.txt containing "PR test deterministic commit"
/// - 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: &str = "5d40fb1555a0c28bf4d650515a73aaa54d4d9bfb";
/// 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) 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,
/// PR (Pull Request) event for the SAME repo_id as ValidRepo
/// - Requires ValidRepo (uses same repo_id)
/// - Signed by `client.pr_author_keys()`
/// - Kind 1618 (NIP-34 PR)
/// - Includes `a` tag referencing the repo
/// - Includes `c` tag pointing to PR_TEST_COMMIT_HASH
/// - Timestamp: 1 second in the past
PREvent,
/// PR event generated (built) but NOT sent to relay
///
/// This is a "Generated" stage fixture - the event is created but not published.
/// Useful for tests that need the PR event ID before the event exists on the relay.
///
/// - Requires ValidRepo (uses same repo_id)
/// - Signed by `client.pr_author_keys()`
/// - Kind 1618 (NIP-34 PR)
/// - Includes `c` tag pointing to PR_TEST_COMMIT_HASH
/// - NOT sent to relay (use `client.send_event()` to publish when ready)
PREventGenerated,
/// HEAD set to 'develop' branch via state event
///
/// This fixture tests that HEAD is updated when a state event is published
/// with HEAD pointing to a different branch that already has git data pushed.
///
/// GRASP-01: "MUST set repository HEAD per repository state announcement
/// as soon as the git data related to that branch has been received."
///
/// Stages:
/// 1. **Depends on**: RecursiveMaintainerStateDataPushed (all git data exists on main)
/// 2. **Creates**: New state event with HEAD=refs/heads/develop pointing to existing commit
/// 3. **Sends**: State event to relay
/// 4. **Verifies**: Can be checked via get_default_branch_from_info_refs
///
/// - Requires RecursiveMaintainerStateDataPushed (establishes full maintainer chain with git data)
/// - Creates state event signed by maintainer keys (`client.maintainer_keys()`)
/// - Points refs/heads/develop to RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH
/// - Sets HEAD to refs/heads/develop
HeadSetToDevelopBranch,
/// Wrong commit pushed to refs/nostr/<pr-event-id> BEFORE PR event is sent
///
/// This is a "DataPushed" stage fixture for testing pre-event ref behavior.
/// The server has refs/nostr/<pr-event-id> pointing to DETERMINISTIC_COMMIT_HASH
/// (the "wrong" commit), but no PR event exists yet on the relay.
///
/// Server state after this fixture:
/// - ValidRepo announcement on relay
/// - refs/nostr/<pr-event-id> exists on git server with wrong commit
/// - PR event is NOT on relay (but returned for tests to publish later)
///
/// - Requires PREventGenerated (for the event ID)
/// - Clones repo, creates wrong commit, pushes to refs/nostr/<event-id>
/// - Returns: the unsent PR event (tests can publish it later)
PRWrongCommitPushedBeforeEvent,
/// PR event sent to relay AFTER wrong commit was pushed to refs/nostr/<pr-event-id>
///
/// This is a compound fixture testing post-event behavior.
/// The server had refs/nostr/<pr-event-id> pointing to wrong commit,
/// then the PR event was published (which may trigger cleanup).
///
/// Server state after this fixture:
/// - ValidRepo announcement on relay
/// - PR event is on relay
/// - refs/nostr/<pr-event-id> may have been cleaned up (that's what tests verify)
///
/// - Requires PRWrongCommitPushedBeforeEvent
/// - Sends the PR event to relay
/// - Returns: the sent PR event
PREventSentAfterWrongPush,
/// Owner's state event with git data successfully pushed (full 4-stage fixture)
///
/// This fixture represents the complete flow for testing state push authorization:
/// 1. **Generated**: Creates RepoState (repo announcement + state event)
/// 2. **Sent**: Sends events to relay (returns OK, accepted but 'purgatory:...' message)
/// 3. **Verify Not Served**: Confirms event is not served by relays
/// 4. **DataPushed**: Clones repo, creates deterministic commit, pushes to relay
/// 5. **Verified**: Confirms event is served by relay
///
/// - Requires ValidRepo (uses same repo_id)
/// - State event signed by owner keys (`client.keys()`)
/// - Points to DETERMINISTIC_COMMIT_HASH
/// - Git push verified to succeed (state matches pushed commit)
OwnerStateDataPushed,
/// Maintainer's state event with git data successfully pushed (full 5-stage fixture)
///
/// This fixture tests that a maintainer can authorize pushes with ONLY a state event,
/// without publishing their own repo announcement.
///
/// This fixture represents the complete flow for testing maintainer push authorization:
/// 1. **OwnerStateDataPushed dependency**: Owner's repo and state event already on relay, git data pushed
/// 2. **Sent**: Sends maintainer state event to relay (returns OK, accepted but 'purgatory:...' message)
/// 3. **Verify Not Served**: Confirms event is not served by relays
/// 4. **DataPushed**: Clones repo, creates maintainer deterministic commit, force-pushes to relay
/// 5. **Verified**: Confirms event is served by relay
///
/// - Requires OwnerStateDataPushed (owner's data already pushed to git)
/// - State event signed by maintainer keys (`client.maintainer_keys()`)
/// - Points to MAINTAINER_DETERMINISTIC_COMMIT_HASH
/// - Git push verified to succeed (force push with maintainer's state event authorizes the commit)
MaintainerStateDataPushed,
/// Recursive maintainer's state event with git data successfully pushed (full 5-stage fixture)
///
/// This fixture tests that a recursive maintainer (authorized via maintainer chain) can
/// authorize pushes. The recursive maintainer is listed in the maintainer's announcement,
/// not the owner's announcement, so this tests the recursive maintainer traversal.
///
/// This fixture represents the complete flow for testing recursive maintainer push authorization:
/// 1. **Generated**: (MaintainerStateDataPushed dependency includes ValidRepo + OwnerStateDataPushed)
/// Creates MaintainerAnnouncement + RecursiveMaintainerState
/// 2. **Sent**: Sends events to relay (returns OK, accepted but 'purgatory:...' message)
/// 3. **Verify Not Served**: Confirms event is not served by relays
/// 4. **DataPushed**: Clones repo, creates recursive maintainer deterministic commit, pushes to relay
/// 5. **Verified**: Confirms event is served by relay
///
/// Chain: Owner -> Maintainer -> RecursiveMaintainer
///
/// - Requires MaintainerStateDataPushed (establishes Owner -> Maintainer chain with git data)
/// - State event signed by recursive maintainer keys (`client.recursive_maintainer_keys()`)
/// - Points to RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH
/// - Git push verified to succeed (recursive maintainer's state event authorizes the commit)
RecursiveMaintainerStateDataPushed,
}
impl FixtureKind {
/// Get the fixture dependencies that must be ensured before this one
///
/// Dependencies are processed in order and cached, so if a fixture
/// depends on another that's already been created, it won't be recreated.
pub fn dependencies(&self) -> Vec<FixtureKind> {
match self {
// Base fixtures - no dependencies
Self::ValidRepo => vec![],
// Fixtures that depend on ValidRepo
Self::RepoWithIssue => vec![Self::ValidRepo],
Self::RepoState => vec![Self::ValidRepo],
Self::PREvent => vec![Self::ValidRepo],
Self::PREventGenerated => vec![Self::ValidRepo],
Self::PRWrongCommitPushedBeforeEvent => vec![Self::PREventGenerated],
Self::PREventSentAfterWrongPush => vec![Self::PRWrongCommitPushedBeforeEvent],
Self::OwnerStateDataPushed => vec![Self::ValidRepo],
// Fixtures that depend on RepoWithIssue
Self::RepoWithComment => vec![Self::RepoWithIssue],
// MaintainerStateDataPushed depends on OwnerStateDataPushed
// (maintainer force-pushes over owner's data)
Self::MaintainerStateDataPushed => vec![Self::OwnerStateDataPushed],
// RecursiveMaintainerStateDataPushed depends on MaintainerStateDataPushed
// (recursive maintainer force-pushes over maintainer's data)
Self::RecursiveMaintainerStateDataPushed => vec![Self::MaintainerStateDataPushed],
// HeadSetToDevelopBranch depends on RecursiveMaintainerStateDataPushed
// (all git data already exists, we just publish a new state event)
Self::HeadSetToDevelopBranch => vec![Self::RecursiveMaintainerStateDataPushed],
}
}
/// Whether this fixture sends its own events to the relay
///
/// Some fixtures (like DataPushed variants) handle event sending internally
/// as part of their build process. For these, the generic ensure_fixture
/// should NOT send the event again.
pub fn sends_own_events(&self) -> bool {
match self {
// These fixtures send events and push git data internally
Self::OwnerStateDataPushed => true,
Self::MaintainerStateDataPushed => true,
Self::RecursiveMaintainerStateDataPushed => true,
// PREventGenerated builds but does NOT send the PR event (that's the point)
Self::PREventGenerated => true,
// PRWrongCommitPushedBeforeEvent pushes git data but doesn't send event
Self::PRWrongCommitPushedBeforeEvent => true,
// PREventSentAfterWrongPush sends the PR event internally
Self::PREventSentAfterWrongPush => true,
// HeadSetToDevelopBranch sends its state event internally
Self::HeadSetToDevelopBranch => true,
// All other fixtures return a single event for the caller to send
_ => false,
}
}
}
/// Context mode for fixture management
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ContextMode {
/// Create fresh fixtures for each request (test isolation)
Isolated,
/// Reuse shared fixtures across requests (minimal events)
Shared,
}
impl From<AuditMode> for ContextMode {
fn from(mode: AuditMode) -> Self {
match mode {
AuditMode::Isolated => ContextMode::Isolated,
AuditMode::Shared => ContextMode::Shared,
}
}
}
/// Test context for managing prerequisite events
///
/// The TestContext provides mode-aware fixture management:
/// - In **Isolated mode**: Each TestContext has its own local cache, creating fresh
/// fixtures for each test. Use this for `cargo test` where tests run in parallel.
/// - In **Shared mode**: Uses the client's fixture cache, shared across all TestContexts.
/// Use this for CLI audit where tests run sequentially and build on each other.
///
/// # Mode Selection
///
/// The mode is determined by `AuditConfig::mode`:
/// - `AuditConfig::isolated()` → Creates fresh fixtures per TestContext
/// - `AuditConfig::shared()` → Reuses fixtures across all TestContexts (default for CLI)
///
/// # Example
///
/// ```no_run
/// # use grasp_audit::*;
/// # async fn example() -> anyhow::Result<()> {
/// // For CLI audit (shared fixtures - default)
/// let config = AuditConfig::shared();
/// let client = AuditClient::new("ws://localhost:7000", config).await?;
/// let ctx = TestContext::new(&client);
///
/// // Get a repository fixture - will be reused by subsequent TestContexts
/// let repo = ctx.get_fixture(FixtureKind::ValidRepo).await?;
///
/// // For cargo test (isolated fixtures)
/// let config = AuditConfig::isolated();
/// let client = AuditClient::new("ws://localhost:7000", config).await?;
/// let ctx = TestContext::new(&client);
///
/// // Get a repository fixture - fresh for this TestContext only
/// let repo = ctx.get_fixture(FixtureKind::ValidRepo).await?;
/// # Ok(())
/// # }
/// ```
pub struct TestContext<'a> {
client: &'a AuditClient,
mode: ContextMode,
/// Per-TestContext cache for Isolated mode
/// This cache ensures fixture dependencies work within a single test
/// while maintaining isolation between tests.
/// In Shared mode, this cache is not used - we use the client's cache instead.
local_cache: Arc<Mutex<HashMap<FixtureKind, Event>>>,
}
impl<'a> TestContext<'a> {
/// Create a new test context
///
/// The context mode is automatically determined from the client's audit config.
/// In Isolated mode, fixtures are cached per-TestContext to maintain fixture
/// dependencies within a test while ensuring isolation between tests.
/// In Shared mode, the client's cache is used for cross-test fixture sharing.
pub fn new(client: &'a AuditClient) -> Self {
let mode = ContextMode::from(client.config.mode);
Self {
client,
mode,
local_cache: Arc::new(Mutex::new(HashMap::new())),
}
}
/// Create a test context with explicit mode override
///
/// This is useful for testing the context itself or for advanced use cases
/// where you want to override the default mode behavior.
pub fn with_mode(client: &'a AuditClient, mode: ContextMode) -> Self {
Self {
client,
mode,
local_cache: Arc::new(Mutex::new(HashMap::new())),
}
}
/// Get a fixture, creating it if needed based on mode
///
/// This is an alias for `ensure_fixture` - the core method for fixture management.
/// It automatically handles:
/// - Mode-aware caching (Isolated vs Shared)
/// - Dependency resolution
/// - Event sending
///
/// # Example
///
/// ```no_run
/// # use grasp_audit::*;
/// # async fn example(ctx: &TestContext<'_>) -> anyhow::Result<()> {
/// let repo = ctx.get_fixture(FixtureKind::ValidRepo).await?;
/// # Ok(())
/// # }
/// ```
pub async fn get_fixture(&self, kind: FixtureKind) -> Result<Event> {
self.ensure_fixture(kind).await
}
/// Get the underlying client for direct access
///
/// This allows tests to use the client directly when needed while still
/// benefiting from the TestContext for fixture management.
pub fn client(&self) -> &'a AuditClient {
self.client
}
/// Get the current context mode
pub fn mode(&self) -> ContextMode {
self.mode
}
// ============================================================
// Cache Helper Methods
// ============================================================
/// Get a cached fixture if it exists
fn get_cached(&self, kind: FixtureKind) -> Option<Event> {
match self.mode {
ContextMode::Isolated => {
let cache = self.local_cache.lock().unwrap();
cache.get(&kind).cloned()
}
ContextMode::Shared => {
let cache = self.client.fixture_cache().lock().unwrap();
cache.get(&kind).cloned()
}
}
}
/// Store a fixture in the cache
fn store_cached(&self, kind: FixtureKind, event: Event) {
match self.mode {
ContextMode::Isolated => {
let mut cache = self.local_cache.lock().unwrap();
cache.insert(kind, event);
tracing::debug!(
"store_cached({:?}) stored in local cache ({} entries)",
kind,
cache.len()
);
}
ContextMode::Shared => {
let mut cache = self.client.fixture_cache().lock().unwrap();
cache.insert(kind, event);
tracing::debug!(
"store_cached({:?}) stored in client cache ({} entries)",
kind,
cache.len()
);
}
}
}
// ============================================================
// Core Fixture Methods
// ============================================================
/// Ensure a fixture exists (with all dependencies)
///
/// This is the core method for fixture management. It:
/// 1. Checks the cache, returning immediately if found
/// 2. Ensures all dependencies are met (recursively)
/// 3. Builds the fixture
/// 4. Sends to relay (unless fixture handles this internally)
/// 5. Caches and returns the result
///
/// # Example
///
/// ```no_run
/// # use grasp_audit::*;
/// # async fn example(ctx: &TestContext<'_>) -> anyhow::Result<()> {
/// // This ensures ValidRepo exists first, then creates MaintainerState
/// let state = ctx.ensure_fixture(FixtureKind::MaintainerState).await?;
/// # Ok(())
/// # }
/// ```
pub fn ensure_fixture(
&self,
kind: FixtureKind,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Event>> + Send + '_>> {
Box::pin(async move {
// Check cache first
if let Some(cached) = self.get_cached(kind) {
tracing::debug!("ensure_fixture({:?}) found in cache", kind);
return Ok(cached);
}
// Check relay connection before proceeding
if !self.client.is_connected().await {
return Err(anyhow::anyhow!(
"Relay connection lost before creating {:?} fixture",
kind
));
}
// Ensure all dependencies are met first
for dep in kind.dependencies() {
tracing::debug!("ensure_fixture({:?}) ensuring dependency {:?}", kind, dep);
self.ensure_fixture(dep).await.with_context(|| {
format!("Failed to ensure dependency {:?} for {:?}", dep, kind)
})?;
}
// Build the fixture
let event = self
.build_fixture_inner(kind)
.await
.with_context(|| format!("Failed to build {:?} fixture", kind))?;
// Send to relay if this fixture doesn't handle it internally
if !kind.sends_own_events() {
self.client
.send_event(event.clone())
.await
.with_context(|| format!("Failed to send {:?} fixture event to relay", kind))?;
}
// Cache and return
self.store_cached(kind, event.clone());
Ok(event)
})
}
/// Build a fixture event WITHOUT publishing it to the relay.
///
/// This is useful for tests that need to get a fixture's event ID before
/// actually publishing it. For example, testing refs/nostr/<event-id>
/// behavior before the corresponding event exists on the relay.
///
/// Note: This ensures dependencies are created/published first, but the
/// requested fixture itself will NOT be published.
///
/// # Example
///
/// ```no_run
/// # use grasp_audit::*;
/// # async fn example(ctx: &TestContext<'_>) -> anyhow::Result<()> {
/// // Build PR event to get its ID without publishing
/// let pr_event = ctx.build_fixture_only(FixtureKind::PREvent).await?;
/// let pr_event_id = pr_event.id.to_hex();
///
/// // Now push to refs/nostr/<pr_event_id> before event exists
/// // ... git push ...
///
/// // Later, publish the PR event when ready
/// ctx.client().send_event(pr_event).await?;
/// # Ok(())
/// # }
/// ```
pub async fn build_fixture_only(&self, kind: FixtureKind) -> Result<Event> {
// Ensure dependencies are met first
for dep in kind.dependencies() {
self.ensure_fixture(dep).await?;
}
// Build but don't send/cache
self.build_fixture_inner(kind).await
}
/// Get a cached dependency (assumes ensure_fixture processed dependencies first)
///
/// This is a convenience helper for build_fixture_inner to retrieve dependencies
/// that were already ensured by ensure_fixture before calling build_fixture_inner.
fn get_cached_dependency(&self, kind: FixtureKind) -> Result<Event> {
self.get_cached(kind).ok_or_else(|| {
anyhow::anyhow!(
"Dependency {:?} not found in cache - this is a bug in fixture dependencies",
kind
)
})
}
/// Build a fixture event (internal - assumes dependencies are cached)
///
/// This method is called by `ensure_fixture` after all dependencies have been
/// ensured and cached. It should NOT call `ensure_fixture` or it will cause
/// infinite recursion. Instead, use `get_cached_dependency` to retrieve
/// already-cached dependencies.
async fn build_fixture_inner(&self, kind: FixtureKind) -> Result<Event> {
match kind {
FixtureKind::ValidRepo => {
// ValidRepo has no dependencies - create a new repo announcement
let test_name = format!(
"fixture-ValidRepo-{}",
&uuid::Uuid::new_v4().to_string()[..8]
);
self.client
.create_repo_announcement(&test_name)
.await
.with_context(|| format!("create_repo_announcement failed for {}", test_name))
}
FixtureKind::RepoWithIssue => {
// ValidRepo is ensured by ensure_fixture before this is called
let repo = self.get_cached_dependency(FixtureKind::ValidRepo)?;
// Build issue referencing it - caller will send it
self.client
.create_issue(&repo, "Test Issue", "Issue content for testing", vec![])
}
FixtureKind::RepoWithComment => {
// RepoWithIssue is ensured by ensure_fixture before this is called
let issue = self.get_cached_dependency(FixtureKind::RepoWithIssue)?;
// Build comment on issue - caller will send it
self.client.create_comment(&issue, "Test comment", vec![])
}
FixtureKind::RepoState => {
use nostr_sdk::prelude::*;
// ValidRepo is ensured by ensure_fixture before this is called
let repo = self.get_cached_dependency(FixtureKind::ValidRepo)?;
// Extract repo_id from repo announcement
let repo_id = repo
.tags
.iter()
.find(|t| t.kind() == TagKind::d())
.and_then(|t| t.content())
.ok_or_else(|| anyhow::anyhow!("Missing d tag in repo announcement"))?
.to_string();
// Create state announcement with deterministic commit hash
let base_time = Timestamp::now().as_secs();
let older_timestamp = Timestamp::from(base_time - 10); // 10 seconds ago
// Tag format: ["refs/heads/main", "<commit_hash>"]
// Note: We build the state but DON'T send it here - the caller will send it
self.client
.event_builder(Kind::RepoState, "")
.tag(Tag::identifier(&repo_id))
.tag(Tag::custom(
TagKind::custom("refs/heads/main"),
vec![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.keys())
.map_err(|e| anyhow::anyhow!("Failed to build state announcement: {}", e))
}
FixtureKind::PREvent => {
use nostr_sdk::prelude::*;
// ValidRepo is ensured by ensure_fixture before this is called
let repo = self.get_cached_dependency(FixtureKind::ValidRepo)?;
let repo_id = repo
.tags
.iter()
.find(|t| t.kind() == TagKind::d())
.and_then(|t| t.content())
.ok_or_else(|| anyhow::anyhow!("Missing repo_id in ValidRepo fixture"))?
.to_string();
// Create PR event 1 second in the past
let base_time = Timestamp::now().as_secs();
let pr_timestamp = Timestamp::from(base_time - 1);
// Build NIP-34 PR event (kind 1618)
self.client
.event_builder(
Kind::GitPullRequest, // NIP-34 PR kind (has 'c' tag for commit)
"Test PR for GRASP validation",
)
.tag(Tag::custom(
TagKind::custom("a"),
vec![format!(
"30617:{}:{}",
self.client.public_key().to_hex(), // Owner pubkey
repo_id
)],
))
.tag(Tag::custom(
TagKind::custom("c"),
vec![PR_TEST_COMMIT_HASH.to_string()],
))
.custom_time(pr_timestamp)
.build(self.client.pr_author_keys())
.map_err(|e| anyhow::anyhow!("Failed to build PR event: {}", e))
}
FixtureKind::PREventGenerated => {
// Same as PREvent but will NOT be sent to relay (caller may send it later)
// This fixture is for "Generated" stage only
use nostr_sdk::prelude::*;
// ValidRepo is ensured by ensure_fixture before this is called
let repo = self.get_cached_dependency(FixtureKind::ValidRepo)?;
let repo_id = repo
.tags
.iter()
.find(|t| t.kind() == TagKind::d())
.and_then(|t| t.content())
.ok_or_else(|| anyhow::anyhow!("Missing repo_id in ValidRepo fixture"))?
.to_string();
// Create PR event 1 second in the past
let base_time = Timestamp::now().as_secs();
let pr_timestamp = Timestamp::from(base_time - 1);
// Build NIP-34 PR event (kind 1618)
self.client
.event_builder(
Kind::GitPullRequest, // NIP-34 PR kind (has 'c' tag for commit)
"Test PR for GRASP validation",
)
.tag(Tag::custom(
TagKind::custom("a"),
vec![format!(
"30617:{}:{}",
self.client.public_key().to_hex(), // Owner pubkey
repo_id
)],
))
.tag(Tag::custom(
TagKind::custom("c"),
vec![PR_TEST_COMMIT_HASH.to_string()],
))
.custom_time(pr_timestamp)
.build(self.client.pr_author_keys())
.map_err(|e| anyhow::anyhow!("Failed to build PR event: {}", e))
}
FixtureKind::PRWrongCommitPushedBeforeEvent => {
self.build_pr_wrong_commit_pushed_before_event().await
}
FixtureKind::PREventSentAfterWrongPush => {
self.build_pr_event_sent_after_wrong_push().await
}
FixtureKind::OwnerStateDataPushed => self.build_owner_state_data_pushed().await,
FixtureKind::MaintainerStateDataPushed => {
self.build_maintainer_state_data_pushed().await
}
FixtureKind::RecursiveMaintainerStateDataPushed => {
self.build_recursive_maintainer_state_data_pushed().await
}
FixtureKind::HeadSetToDevelopBranch => self.build_head_set_to_develop_branch().await,
}
}
/// Build maintainer announcement event for the given repo_id
async fn build_maintainer_announcement(&self, repo_id: &str) -> Result<Event> {
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))
}
/// Extract repo_id from a repo announcement event
fn extract_repo_id(&self, repo: &Event) -> Result<String> {
use nostr_sdk::prelude::*;
repo.tags
.iter()
.find(|t| t.kind() == TagKind::d())
.and_then(|t| t.content())
.map(|s| s.to_string())
.ok_or_else(|| anyhow::anyhow!("Missing d tag in repo announcement"))
}
/// Build OwnerStateDataPushed fixture: full 4-stage fixture for push authorization
///
/// This handles all stages of the fixture:
/// 1. **Generated**: Creates RepoState (repo announcement + state event)
/// 2. **Sent**: Sends events to relay (returns OK, accepted but 'purgatory:...' message)
/// 3. **Verify Not Served**: Confirms event is not served by relays
/// 4. **DataPushed**: Clones repo, creates deterministic commit, pushes to relay
/// 5. **Verified**: Confirms event is served by relay
///
/// # Returns
/// The state event (kind 30618) after all stages complete successfully
async fn build_owner_state_data_pushed(&self) -> Result<Event> {
use nostr_sdk::prelude::*;
// ============================================================
// Stage 1: ValidRepo is ensured by ensure_fixture before this is called
// ============================================================
let repo = self.get_cached_dependency(FixtureKind::ValidRepo)?;
let repo_id = self.extract_repo_id(&repo)?;
// Build state event
let base_time = Timestamp::now().as_secs();
let older_timestamp = Timestamp::from(base_time - 10); // 10 seconds ago
let state_event = self
.client
.event_builder(Kind::RepoState, "")
.tag(Tag::identifier(&repo_id))
.tag(Tag::custom(
TagKind::custom("refs/heads/main"),
vec![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.keys())
.map_err(|e| anyhow::anyhow!("Failed to build state announcement: {}", e))?;
// ============================================================
// Stage 2 & 3: Send to Relay, get Accepted response and Verify its Not Served
// ============================================================
self.client
.send_event_expect_purgatory_not_served(state_event.clone())
.await?;
// ============================================================
// Stage 4: DataPushed - Clone repo, create commit, push
// ============================================================
// Get relay domain from connected relay
let relay_domain = self.get_relay_domain().await?;
let npub = state_event
.pubkey
.to_bech32()
.map_err(|e| anyhow::anyhow!("Failed to convert pubkey to bech32: {}", e))?;
// Clone the repository
let clone_path = clone_repo(&relay_domain, &npub, &repo_id)
.map_err(|e| anyhow::anyhow!("Failed to clone repo: {}", e))?;
// Cleanup helper (always clean up on error or success)
let cleanup = |path: &PathBuf| {
let _ = fs::remove_dir_all(path);
};
// Create deterministic commit locally
let commit_hash = match create_deterministic_commit(&clone_path, "Initial commit") {
Ok(h) => h,
Err(e) => {
cleanup(&clone_path);
return Err(anyhow::anyhow!(
"Failed to create deterministic commit: {}",
e
));
}
};
// Verify commit hash matches expected
if commit_hash != DETERMINISTIC_COMMIT_HASH {
cleanup(&clone_path);
return Err(anyhow::anyhow!(
"Commit hash mismatch: got {}, expected {}",
commit_hash,
DETERMINISTIC_COMMIT_HASH
));
}
// Create main branch pointing to our deterministic commit
let branch_output = Command::new("git")
.args(["branch", "main"])
.current_dir(&clone_path)
.output();
match branch_output {
Err(e) => {
cleanup(&clone_path);
return Err(anyhow::anyhow!("Failed to create main branch: {}", e));
}
Ok(output) if !output.status.success() => {
cleanup(&clone_path);
return Err(anyhow::anyhow!(
"Failed to create main branch: {}",
String::from_utf8_lossy(&output.stderr)
));
}
_ => {}
}
// Checkout main branch
let checkout_output = Command::new("git")
.args(["checkout", "main"])
.current_dir(&clone_path)
.output();
match checkout_output {
Err(e) => {
cleanup(&clone_path);
return Err(anyhow::anyhow!("Failed to checkout main branch: {}", e));
}
Ok(output) if !output.status.success() => {
cleanup(&clone_path);
return Err(anyhow::anyhow!(
"Failed to checkout main branch: {}",
String::from_utf8_lossy(&output.stderr)
));
}
_ => {}
}
// Push to relay
let push_result = try_push(&clone_path);
cleanup(&clone_path);
match push_result {
Ok(res) => {
if !res {
return Err(anyhow::anyhow!(
"Push was rejected but should have been accepted. \
The state event points to commit {} which matches the pushed commit.",
DETERMINISTIC_COMMIT_HASH
));
}
}
Err(e) => return Err(anyhow::anyhow!("Push error: {}", e)),
}
// ============================================================
// Stage 5: Verify state event is on relay
// ============================================================
tokio::time::sleep(Duration::from_millis(200)).await;
if !self.client.is_event_on_relay(state_event.id).await? {
return Err(anyhow::anyhow!("state event not released from purgatory"));
}
Ok(state_event)
}
/// Build MaintainerStateDataPushed fixture: full 5-stage fixture for maintainer push authorization
///
/// This tests that a maintainer can authorize pushes with ONLY a state event,
/// without publishing their own repo announcement.
///
/// Depends on OwnerStateDataPushed - the owner's data has already been pushed.
/// The maintainer force-pushes their commit on top.
///
/// This handles all stages of the fixture:
/// 1. **OwnerStateDataPushed dependency**: Owner's repo and state event already on relay, git data pushed
/// 2. **Sent**: Sends maintainer state event to relay (returns OK, accepted but 'purgatory:...' message)
/// 3. **Verify Not Served**: Confirms event is not served by relays
/// 4. **DataPushed**: Clones repo, creates maintainer deterministic commit, force-pushes to relay
/// 5. **Verified**: Confirms event is served by relay
///
/// # Returns
/// The maintainer's state event (kind 30618) after all stages complete successfully
async fn build_maintainer_state_data_pushed(&self) -> Result<Event> {
use nostr_sdk::prelude::*;
// ============================================================
// Stage 1: OwnerStateDataPushed is ensured by ensure_fixture before this is called
// ============================================================
let owner_state = self.get_cached_dependency(FixtureKind::OwnerStateDataPushed)?;
// Extract repo_id from owner's state event (same d-tag structure)
let repo_id = self.extract_repo_id(&owner_state)?;
// Get the repo (ValidRepo, also cached) for the owner's npub
let repo = self.get_cached_dependency(FixtureKind::ValidRepo)?;
// Build maintainer's state event (state event ONLY - no announcement)
let base_time = Timestamp::now().as_secs();
let maintainer_timestamp = Timestamp::from(base_time - 5); // 5 seconds ago (more recent than owner's state)
let maintainer_state_event = self
.client
.event_builder(Kind::RepoState, "")
.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(maintainer_timestamp)
.build(self.client.maintainer_keys())
.map_err(|e| anyhow::anyhow!("Failed to build maintainer state event: {}", e))?;
// ============================================================
// Stage 2 & 3: Send to Relay, get Accepted response and Verify its Not Served
// ============================================================
self.client
.send_event_expect_purgatory_not_served(maintainer_state_event.clone())
.await?;
// ============================================================
// Stage 4: DataPushed - Clone repo, create maintainer commit, push
// ============================================================
// Get relay domain from connected relay
let relay_domain = self.get_relay_domain().await?;
// Use owner's npub for cloning (repo belongs to owner)
let npub = repo
.pubkey
.to_bech32()
.map_err(|e| anyhow::anyhow!("Failed to convert pubkey to bech32: {}", e))?;
// Clone the repository
let clone_path = clone_repo(&relay_domain, &npub, &repo_id)
.map_err(|e| anyhow::anyhow!("Failed to clone repo: {}", e))?;
// Cleanup helper (always clean up on error or success)
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", "main-new"])
.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: Create deterministic commit using maintainer variant
let commit_hash = match create_deterministic_commit_with_variant(
&clone_path,
CommitVariant::Maintainer,
) {
Ok(h) => h,
Err(e) => {
cleanup(&clone_path);
return Err(anyhow::anyhow!("Failed to create maintainer commit: {}", e));
}
};
// Step 4: Replace main branch with our new orphan branch
let _ = Command::new("git")
.args(["branch", "-D", "main"])
.current_dir(&clone_path)
.output();
let _ = Command::new("git")
.args(["branch", "-m", "main"])
.current_dir(&clone_path)
.output();
// Verify commit hash matches expected
if commit_hash != MAINTAINER_DETERMINISTIC_COMMIT_HASH {
cleanup(&clone_path);
return Err(anyhow::anyhow!(
"Maintainer commit hash mismatch: got {}, expected {}",
commit_hash,
MAINTAINER_DETERMINISTIC_COMMIT_HASH
));
}
// Push to relay
let push_result = try_push(&clone_path);
cleanup(&clone_path);
match push_result {
Ok(res) => {
if !res {
return Err(anyhow::anyhow!(
"Push was rejected but should have been accepted. \
The maintainer published a state event with commit {}, \
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.",
MAINTAINER_DETERMINISTIC_COMMIT_HASH
));
}
}
Err(e) => return Err(anyhow::anyhow!("Push error: {}", e)),
}
// ============================================================
// Stage 5: Verify state event is on relay
// ============================================================
tokio::time::sleep(Duration::from_millis(200)).await;
if !self
.client
.is_event_on_relay(maintainer_state_event.id)
.await?
{
return Err(anyhow::anyhow!("state event not released from purgatory"));
}
Ok(maintainer_state_event)
}
/// Build RecursiveMaintainerStateDataPushed fixture: full 5-stage fixture for recursive maintainer push authorization
///
/// This tests that a recursive maintainer (authorized via maintainer chain) can authorize pushes.
/// The recursive maintainer is listed in the maintainer's announcement, not the owner's announcement,
/// so this tests the recursive maintainer traversal (Owner -> Maintainer -> RecursiveMaintainer).
///
/// Depends on MaintainerStateDataPushed - the maintainer's data has already been pushed.
/// We then send the MaintainerAnnouncement (which lists the recursive maintainer), and the
/// recursive maintainer force-pushes their commit on top.
///
/// This handles all stages of the fixture:
/// 1. **Generated**: (MaintainerStateDataPushed dependency includes ValidRepo + OwnerStateDataPushed)
/// Creates MaintainerAnnouncement + RecursiveMaintainerState
/// 2. **Sent**: Sends events to relay (returns OK, accepted but 'purgatory:...' message)
/// 3. **Verify Not Served**: Confirms event is not served by relays
/// 4. **DataPushed**: Clones repo, creates recursive maintainer deterministic commit, pushes to relay
/// 5. **Verified**: Confirms event is served by relay
///
/// # Returns
/// The recursive maintainer's state event (kind 30618) after all stages complete successfully
async fn build_recursive_maintainer_state_data_pushed(&self) -> Result<Event> {
use nostr_sdk::prelude::*;
// ============================================================
// Stage 1: MaintainerStateDataPushed is ensured by ensure_fixture before this is called
// ============================================================
let maintainer_state =
self.get_cached_dependency(FixtureKind::MaintainerStateDataPushed)?;
// Extract repo_id from maintainer's state event (same d-tag structure)
let repo_id = self.extract_repo_id(&maintainer_state)?;
// Get the repo (ValidRepo, also cached) for the owner's npub
let repo = self.get_cached_dependency(FixtureKind::ValidRepo)?;
// ============================================================
// Stage 1 (continued): Generate MaintainerAnnouncement and RecursiveMaintainerState
// ============================================================
let maintainer_announcement = self.build_maintainer_announcement(&repo_id).await?;
self.client.send_event(maintainer_announcement).await?;
// Build recursive maintainer's state event
let base_time = Timestamp::now().as_secs();
let recursive_maintainer_timestamp = Timestamp::from(base_time - 2); // 2 seconds ago (most recent)
let recursive_maintainer_state_event = self
.client
.event_builder(Kind::RepoState, "")
.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(recursive_maintainer_timestamp)
.build(self.client.recursive_maintainer_keys())
.map_err(|e| {
anyhow::anyhow!("Failed to build recursive maintainer state event: {}", e)
})?;
// ============================================================
// Stage 2 & 3: Send to Relay, get Accepted response and Verify its Not Served
// ============================================================
self.client
.send_event_expect_purgatory_not_served(recursive_maintainer_state_event.clone())
.await?;
// ============================================================
// Stage 4: DataPushed - Clone repo, create recursive maintainer commit, push
// ============================================================
// Get relay domain from connected relay
let relay_domain = self.get_relay_domain().await?;
// Use owner's npub for cloning (repo belongs to owner)
let npub = repo
.pubkey
.to_bech32()
.map_err(|e| anyhow::anyhow!("Failed to convert pubkey to bech32: {}", e))?;
// Clone the repository
let clone_path = clone_repo(&relay_domain, &npub, &repo_id)
.map_err(|e| anyhow::anyhow!("Failed to clone repo: {}", e))?;
// Cleanup helper (always clean up on error or success)
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", "main-new"])
.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: Create deterministic commit using recursive maintainer variant
let commit_hash = match create_deterministic_commit_with_variant(
&clone_path,
CommitVariant::RecursiveMaintainer,
) {
Ok(h) => h,
Err(e) => {
cleanup(&clone_path);
return Err(anyhow::anyhow!(
"Failed to create recursive maintainer commit: {}",
e
));
}
};
// Step 4: Replace main branch with our new orphan branch
let _ = Command::new("git")
.args(["branch", "-D", "main"])
.current_dir(&clone_path)
.output();
let _ = Command::new("git")
.args(["branch", "-m", "main"])
.current_dir(&clone_path)
.output();
// Verify commit hash matches expected
if commit_hash != RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH {
cleanup(&clone_path);
return Err(anyhow::anyhow!(
"Recursive maintainer commit hash mismatch: got {}, expected {}",
commit_hash,
RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH
));
}
// Push to relay
let push_result = try_push(&clone_path);
cleanup(&clone_path);
match push_result {
Ok(res) => {
if !res {
return Err(anyhow::anyhow!(
"Push was rejected but should have been accepted. \
The recursive maintainer published a state event with commit {}, \
and the relay should authorize pushes matching this state event \
through recursive maintainer traversal (Owner -> Maintainer -> RecursiveMaintainer).",
RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH
));
}
}
Err(e) => return Err(anyhow::anyhow!("Push error: {}", e)),
}
// ============================================================
// Stage 5: Verify state event is on relay
// ============================================================
tokio::time::sleep(Duration::from_millis(200)).await;
if !self
.client
.is_event_on_relay(recursive_maintainer_state_event.id)
.await?
{
return Err(anyhow::anyhow!("state event not released from purgatory"));
}
Ok(recursive_maintainer_state_event)
}
/// Build HeadSetToDevelopBranch fixture: creates state event with HEAD=develop
///
/// This tests that HEAD is updated when a state event is published with HEAD
/// pointing to a different branch that already has git data pushed.
///
/// GRASP-01: "MUST set repository HEAD per repository state announcement
/// as soon as the git data related to that branch has been received."
///
/// Depends on RecursiveMaintainerStateDataPushed - all git data already exists.
/// We just create a new state event with HEAD=refs/heads/develop pointing to
/// the already-pushed commit.
///
/// # Returns
/// The state event (kind 30618) with HEAD=refs/heads/develop after it's sent
async fn build_head_set_to_develop_branch(&self) -> Result<Event> {
use nostr_sdk::prelude::*;
// ============================================================
// Stage 1: RecursiveMaintainerStateDataPushed is ensured by ensure_fixture before this is called
// All git data already exists on the relay (main branch with RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH)
// ============================================================
let recursive_state =
self.get_cached_dependency(FixtureKind::RecursiveMaintainerStateDataPushed)?;
// Extract repo_id from the recursive maintainer's state event
let repo_id = self.extract_repo_id(&recursive_state)?;
// ============================================================
// Stage 2: Create state event with HEAD=refs/heads/develop
// ============================================================
// Use the same commit hash that's already pushed to the relay
// but point HEAD to develop branch instead of main
let base_time = Timestamp::now().as_secs();
let develop_timestamp = Timestamp::from(base_time - 1); // 1 second ago (most recent)
let develop_state_event = self
.client
.event_builder(Kind::RepoState, "")
.tag(Tag::identifier(&repo_id))
.tag(Tag::custom(
TagKind::custom("HEAD"),
vec!["refs/heads/develop".to_string()],
))
.tag(Tag::custom(
TagKind::custom("refs/heads/develop"),
vec![RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH.to_string()],
))
.custom_time(develop_timestamp)
.build(self.client.maintainer_keys())
.map_err(|e| anyhow::anyhow!("Failed to build develop state event: {}", e))?;
// Send state event to relay
self.client.send_event(develop_state_event.clone()).await?;
// Wait for relay to process the state event
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
Ok(develop_state_event)
}
/// Build PRWrongCommitPushedBeforeEvent fixture
///
/// This fixture sets up a scenario where:
/// 1. A repo exists on the relay
/// 2. A PR event is generated (but NOT sent to relay)
/// 3. A wrong commit is pushed to refs/nostr/<pr-event-id>
///
/// Server state after:
/// - ValidRepo announcement on relay
/// - refs/nostr/<pr-event-id> on git server pointing to DETERMINISTIC_COMMIT_HASH (wrong)
/// - NO PR event on relay
///
/// Returns: the unsent PR event (tests can publish it later)
async fn build_pr_wrong_commit_pushed_before_event(&self) -> Result<Event> {
use nostr_sdk::prelude::*;
// Get the cached PREventGenerated (the unsent PR event)
let pr_event = self.get_cached_dependency(FixtureKind::PREventGenerated)?;
let pr_event_id = pr_event.id.to_hex();
// Get the ValidRepo to extract repo info
let repo = self.get_cached_dependency(FixtureKind::ValidRepo)?;
let repo_id = self.extract_repo_id(&repo)?;
// Get relay domain for cloning
let relay_domain = self.get_relay_domain().await?;
// Owner npub for clone URL
let npub = repo
.pubkey
.to_bech32()
.map_err(|e| anyhow::anyhow!("Failed to convert pubkey to bech32: {}", e))?;
// Clone the repository (fresh clone - local repos are never cached)
let clone_path = clone_repo(&relay_domain, &npub, &repo_id)
.map_err(|e| anyhow::anyhow!("Failed to clone repo: {}", e))?;
// Cleanup helper
let cleanup = |path: &PathBuf| {
let _ = fs::remove_dir_all(path);
};
// Create a WRONG commit (Owner variant, not PRTestCommit)
// This commit hash will NOT match what's in the PR event's `c` tag
let wrong_commit_hash =
match create_deterministic_commit_with_variant(&clone_path, CommitVariant::Owner) {
Ok(h) => h,
Err(e) => {
cleanup(&clone_path);
return Err(anyhow::anyhow!("Failed to create wrong commit: {}", e));
}
};
// Verify it's actually different from expected PR commit
if wrong_commit_hash == PR_TEST_COMMIT_HASH {
cleanup(&clone_path);
return Err(anyhow::anyhow!(
"Test setup error: wrong_commit_hash {} equals PR_TEST_COMMIT_HASH",
wrong_commit_hash
));
}
// Create master branch if needed and push to refs/nostr/<pr-event-id>
let _ = Command::new("git")
.args(["branch", "-M", "master"])
.current_dir(&clone_path)
.output();
let push_output = Command::new("git")
.args([
"push",
"origin",
&format!("master: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!(
"Initial push to refs/nostr/{} failed (expected success before PR event exists): {}",
pr_event_id,
stderr
));
}
// Return the unsent PR event (tests can publish it later)
Ok(pr_event)
}
/// Build PREventSentAfterWrongPush fixture
///
/// This fixture builds on PRWrongCommitPushedBeforeEvent by sending the PR event.
/// After this fixture, the relay has:
/// - ValidRepo announcement
/// - PR event
/// - refs/nostr/<pr-event-id> may have been cleaned up (that's what tests verify)
///
/// Returns: the sent PR event
async fn build_pr_event_sent_after_wrong_push(&self) -> Result<Event> {
// Get the PR event that was cached by PRWrongCommitPushedBeforeEvent
let pr_event = self.get_cached_dependency(FixtureKind::PRWrongCommitPushedBeforeEvent)?;
// Send the PR event to relay
self.client.send_event(pr_event.clone()).await?;
// Wait for relay to process
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
// Return the now-sent PR event
Ok(pr_event)
}
/// Get relay domain (host:port) from the connected relay
///
/// Extracts the domain from the relay URL for git HTTP operations.
/// Example: ws://localhost:7000 -> localhost:7000
async fn get_relay_domain(&self) -> Result<String> {
let relay_url = self
.client
.client()
.relays()
.await
.keys()
.next()
.ok_or_else(|| anyhow::anyhow!("No relay connected"))?
.to_string();
// Extract domain from URL (ws://host:port -> host:port)
let domain = relay_url
.replace("ws://", "")
.replace("wss://", "")
.trim_end_matches('/')
.to_string();
Ok(domain)
}
/// Clear the fixture cache
///
/// This clears the client's fixture cache, affecting all TestContext
/// instances using the same client.
pub fn clear_cache(&self) {
let mut cache = self.client.fixture_cache().lock().unwrap();
cache.clear();
}
}
// ============================================================
// Verification Helpers
// ============================================================
/// Send event and verify it was accepted (stored by relay)
///
/// This is a common test pattern helper that:
/// 1. Sends an event to the relay via the client
/// 2. Waits for propagation (100ms)
/// 3. Queries the relay to verify the event was stored
///
/// # Arguments
/// * `client` - The AuditClient to use for sending and querying
/// * `event` - The event to send
/// * `description` - Human-readable description for error messages
///
/// # Returns
/// * `Ok(())` if the event was accepted and stored
/// * `Err(String)` with descriptive error if event was not stored
///
/// # Example
/// ```no_run
/// # use grasp_audit::*;
/// # async fn example(client: &AuditClient, event: nostr_sdk::Event) -> Result<(), String> {
/// send_and_verify_accepted(client, event, "issue referencing repo via 'a' tag").await?;
/// # Ok(())
/// # }
/// ```
pub async fn send_and_verify_accepted(
client: &crate::AuditClient,
event: Event,
description: &str,
) -> Result<(), String> {
use nostr_sdk::prelude::Filter;
use std::time::Duration;
let event_id = event.id;
client
.send_event(event)
.await
.map_err(|e| format!("Failed to send event to relay: {}", e))?;
tokio::time::sleep(Duration::from_millis(100)).await;
let filter = Filter::new().id(event_id);
let events = client
.query(filter)
.await
.map_err(|e| format!("Failed to query relay for verification: {}", e))?;
if events.is_empty() {
return Err(format!("Event should be accepted: {}", description));
}
Ok(())
}
/// Send event and verify it was rejected (NOT stored by relay)
///
/// This is a common test pattern helper that:
/// 1. Sends an event to the relay via the client
/// 2. Handles both explicit rejection errors and silent rejection
/// 3. Verifies the event was NOT stored in the relay
///
/// # Arguments
/// * `client` - The AuditClient to use for sending and querying
/// * `event` - The event to send (expected to be rejected)
/// * `description` - Human-readable description for error messages
///
/// # Returns
/// * `Ok(())` if the event was rejected (not stored)
/// * `Err(String)` if the event was unexpectedly accepted
///
/// # Example
/// ```no_run
/// # use grasp_audit::*;
/// # async fn example(client: &AuditClient, event: nostr_sdk::Event) -> Result<(), String> {
/// send_and_verify_rejected(client, event, "orphan issue with no repo connection").await?;
/// # Ok(())
/// # }
/// ```
pub async fn send_and_verify_rejected(
client: &crate::AuditClient,
event: Event,
description: &str,
) -> Result<(), String> {
use nostr_sdk::prelude::Filter;
use std::time::Duration;
let event_id = event.id;
// Try to send event - rejection may cause send_event to fail with an error
let send_result = client.send_event(event).await;
// If send succeeded, the relay might have accepted it (we'll verify below)
// If send failed, check if it's a rejection error (expected)
if let Err(e) = send_result {
let err_msg = e.to_string().to_lowercase();
// Check if error message indicates rejection (not network/other errors)
if err_msg.contains("rejected") || err_msg.contains("blocked") {
// Expected rejection - verify event is NOT in database
tokio::time::sleep(Duration::from_millis(100)).await;
let filter = Filter::new().id(event_id);
let events = client
.query(filter)
.await
.map_err(|e| format!("Failed to query relay for verification: {}", e))?;
if !events.is_empty() {
return Err(format!(
"Event was rejected but still stored: {}",
description
));
}
return Ok(()); // Rejected as expected
} else {
// Unexpected error (network, etc.)
return Err(format!("Failed to send event to relay: {}", e));
}
}
// Send succeeded, verify event was NOT stored (relay should have rejected)
tokio::time::sleep(Duration::from_millis(100)).await;
let filter = Filter::new().id(event_id);
let events = client
.query(filter)
.await
.map_err(|e| format!("Failed to query relay for verification: {}", e))?;
if !events.is_empty() {
return Err(format!("Event should be rejected: {}", description));
}
Ok(())
}
// ============================================================
// Git Operation Helpers
// ============================================================
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration;
/// Clone a repository from the relay and return the path
///
/// # Arguments
/// * `relay_domain` - The domain of the relay (e.g., "localhost:7000")
/// * `npub` - The bech32 public key of the repository owner
/// * `repo_id` - The repository identifier (d-tag value)
///
/// # Returns
/// * `Ok(PathBuf)` - Path to the cloned repository
/// * `Err(String)` - Error message if clone failed
///
/// # Example
/// ```no_run
/// # use grasp_audit::*;
/// # fn example() -> Result<(), String> {
/// let clone_path = clone_repo("localhost:7000", "npub1...", "my-repo")?;
/// // Use the cloned repo...
/// std::fs::remove_dir_all(&clone_path).ok(); // Cleanup
/// # Ok(())
/// # }
/// ```
pub fn clone_repo(relay_domain: &str, npub: &str, repo_id: &str) -> Result<PathBuf, String> {
let temp_base = std::env::temp_dir();
let clone_dir_name = format!("grasp-push-test-{}", uuid::Uuid::new_v4());
let clone_path = temp_base.join(&clone_dir_name);
let _ = fs::remove_dir_all(&clone_path);
let clone_url = format!("http://{}/{}/{}.git", relay_domain, npub, repo_id);
let output = Command::new("git")
.args(["clone", &clone_url, clone_path.to_str().unwrap()])
.env("GIT_TERMINAL_PROMPT", "0")
.output()
.map_err(|e| format!("Failed to execute git clone: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Git clone failed: {}", stderr));
}
// Configure git user
let _ = Command::new("git")
.args(["config", "user.email", "test@grasp-audit.local"])
.current_dir(&clone_path)
.output();
let _ = Command::new("git")
.args(["config", "user.name", "GRASP Audit Test"])
.current_dir(&clone_path)
.output();
Ok(clone_path)
}
/// Create a commit with a unique file and return the commit hash
///
/// # Arguments
/// * `clone_path` - Path to the git repository
/// * `message` - Commit message
///
/// # Returns
/// * `Ok(String)` - The commit hash
/// * `Err(String)` - Error message if commit failed
///
/// # Example
/// ```no_run
/// # use grasp_audit::*;
/// # use std::path::Path;
/// # fn example() -> Result<(), String> {
/// let commit_hash = create_commit(Path::new("/tmp/my-repo"), "My commit message")?;
/// println!("Created commit: {}", commit_hash);
/// # Ok(())
/// # }
/// ```
pub fn create_commit(clone_path: &Path, message: &str) -> Result<String, String> {
let test_file = clone_path.join(format!("test-{}.txt", uuid::Uuid::new_v4()));
fs::write(&test_file, message).map_err(|e| format!("Failed to write file: {}", e))?;
let filename = test_file.file_name().unwrap().to_str().unwrap();
let output = Command::new("git")
.args(["add", filename])
.current_dir(clone_path)
.output()
.map_err(|e| format!("Git add failed: {}", e))?;
if !output.status.success() {
return Err("Git add failed".to_string());
}
let output = Command::new("git")
.args(["-c", "commit.gpgsign=false", "commit", "-m", message])
.current_dir(clone_path)
.output()
.map_err(|e| format!("Git commit failed: {}", e))?;
if !output.status.success() {
return Err("Git commit failed".to_string());
}
let output = Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(clone_path)
.output()
.map_err(|e| format!("Git rev-parse failed: {}", e))?;
if !output.status.success() {
return Err("Failed to get commit hash".to_string());
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
/// Variant of deterministic commit for different pubkey types
/// Each variant produces a different but reproducible commit hash
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommitVariant {
/// Main pubkey variant - uses "Initial commit" content
Owner,
/// Maintainer pubkey variant - uses "Maintainer initial commit" content
Maintainer,
/// Recursive maintainer pubkey variant - uses "Recursive maintainer initial commit" content
RecursiveMaintainer,
/// PR test commit variant - for PR event tests
PRTestCommit,
}
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",
CommitVariant::PRTestCommit => "PR test deterministic 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",
CommitVariant::PRTestCommit => "PR test deterministic commit",
}
}
}
/// Create a deterministic commit with fixed dates and GPG disabled
///
/// The variant parameter allows different commit hashes for different pubkey types:
/// - Owner: uses DETERMINISTIC_COMMIT_HASH
/// - Maintainer: uses MAINTAINER_DETERMINISTIC_COMMIT_HASH
/// - RecursiveMaintainer: uses RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH
///
/// # Arguments
/// * `clone_path` - Path to the git repository
/// * `variant` - The commit variant to create
///
/// # Returns
/// * `Ok(String)` - The deterministic commit hash
/// * `Err(String)` - Error message if commit failed
pub fn create_deterministic_commit_with_variant(
clone_path: &Path,
variant: CommitVariant,
) -> Result<String, String> {
let test_file = clone_path.join("test.txt");
let content = variant.file_content();
let message = variant.commit_message();
fs::write(&test_file, content).map_err(|e| format!("Failed to write file: {}", e))?;
let output = Command::new("git")
.args(["add", "test.txt"])
.current_dir(clone_path)
.output()
.map_err(|e| format!("Git add failed: {}", e))?;
if !output.status.success() {
return Err("Git add failed".to_string());
}
// Create deterministic commit with fixed dates and GPG disabled
let output = Command::new("git")
.args(["-c", "commit.gpgsign=false", "commit", "-m", message])
.env("GIT_AUTHOR_DATE", "2024-01-01T00:00:00Z")
.env("GIT_COMMITTER_DATE", "2024-01-01T00:00:00Z")
.current_dir(clone_path)
.output()
.map_err(|e| format!("Git commit failed: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Git commit failed: {}", stderr));
}
let output = Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(clone_path)
.output()
.map_err(|e| format!("Git rev-parse failed: {}", e))?;
if !output.status.success() {
return Err("Failed to get commit hash".to_string());
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
/// Create a deterministic commit (Owner variant)
///
/// This is a convenience wrapper around `create_deterministic_commit_with_variant`
/// that uses the Owner variant for backwards compatibility.
///
/// # Arguments
/// * `clone_path` - Path to the git repository
/// * `_message` - Ignored for compatibility (Owner variant always uses "Initial commit")
///
/// # Returns
/// * `Ok(String)` - The deterministic commit hash
/// * `Err(String)` - Error message if commit failed
pub fn create_deterministic_commit(clone_path: &Path, _message: &str) -> Result<String, String> {
// Note: message parameter is ignored for backwards compatibility
// The Owner variant always uses "Initial commit"
create_deterministic_commit_with_variant(clone_path, CommitVariant::Owner)
}
/// Attempt a git push and return success/failure
///
/// # Arguments
/// * `clone_path` - Path to the git repository
///
/// # Returns
/// * `Ok(true)` - Push succeeded
/// * `Ok(false)` - Push was rejected
/// * `Err(String)` - Error executing git push
///
/// # Example
/// ```no_run
/// # use grasp_audit::*;
/// # use std::path::Path;
/// # fn example() -> Result<(), String> {
/// let success = try_push(Path::new("/tmp/my-repo"))?;
/// if success {
/// println!("Push succeeded");
/// } else {
/// println!("Push was rejected");
/// }
/// # Ok(())
/// # }
/// ```
pub fn try_push(clone_path: &Path) -> Result<bool, String> {
let output = Command::new("git")
.args(["push", "origin", "main", "--force"])
.current_dir(clone_path)
.env("GIT_TERMINAL_PROMPT", "0")
.output()
.map_err(|e| format!("Failed to execute git push: {}", e))?;
Ok(output.status.success())
}
/// Attempt a git push to a specific ref and return success/failure
///
/// This is used for testing refs/nostr/<event-id> push validation.
///
/// # Arguments
/// * `clone_path` - Path to the git repository
/// * `ref_name` - The ref to push to (e.g., "refs/nostr/<event-id>")
///
/// # Returns
/// * `Ok(true)` - Push succeeded
/// * `Ok(false)` - Push was rejected
/// * `Err(String)` - Error executing git push
///
/// # Example
/// ```no_run
/// # use grasp_audit::*;
/// # use std::path::Path;
/// # fn example() -> Result<(), String> {
/// let success = try_push_to_ref(Path::new("/tmp/my-repo"), "refs/nostr/abc123")?;
/// if success {
/// println!("Push to refs/nostr/abc123 succeeded");
/// } else {
/// println!("Push was rejected");
/// }
/// # Ok(())
/// # }
/// ```
pub fn try_push_to_ref(clone_path: &Path, ref_name: &str) -> Result<bool, String> {
let output = Command::new("git")
.args(["push", "origin", &format!("HEAD:{}", ref_name)])
.current_dir(clone_path)
.env("GIT_TERMINAL_PROMPT", "0")
.output()
.map_err(|e| format!("Failed to execute git push: {}", e))?;
Ok(output.status.success())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::AuditConfig;
#[test]
fn test_context_mode_from_audit_mode() {
assert_eq!(
ContextMode::from(AuditMode::Isolated),
ContextMode::Isolated
);
assert_eq!(ContextMode::from(AuditMode::Shared), ContextMode::Shared);
}
#[test]
fn test_fixture_kind_hash() {
use std::collections::HashSet;
let mut set = HashSet::new();
set.insert(FixtureKind::ValidRepo);
set.insert(FixtureKind::RepoWithIssue);
assert!(set.contains(&FixtureKind::ValidRepo));
assert!(!set.contains(&FixtureKind::RepoWithComment));
}
#[tokio::test]
async fn test_context_creation() {
let config = AuditConfig::isolated();
let client = crate::AuditClient::new_test(config);
let ctx = TestContext::new(&client);
assert_eq!(ctx.mode(), ContextMode::Isolated);
let ctx = TestContext::with_mode(&client, ContextMode::Shared);
assert_eq!(ctx.mode(), ContextMode::Shared);
}
}