From 396da002fefeeb4549e11ff51abf824e91a6ed88 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Wed, 5 Nov 2025 20:13:22 +0000 Subject: restructure grasp01 audit tests and add event acceptance --- grasp-audit/src/client.rs | 76 ++ .../src/specs/grasp01/event_acceptance_policy.rs | 995 +++++++++++++++++++++ grasp-audit/src/specs/grasp01/mod.rs | 9 + grasp-audit/src/specs/grasp01/nip01_smoke.rs | 311 +++++++ grasp-audit/src/specs/grasp01/nip11_document.rs | 165 ++++ grasp-audit/src/specs/grasp01_nostr_relay.rs | 717 --------------- grasp-audit/src/specs/mod.rs | 11 +- grasp-audit/src/specs/nip01_smoke.rs | 311 ------- 8 files changed, 1563 insertions(+), 1032 deletions(-) create mode 100644 grasp-audit/src/specs/grasp01/event_acceptance_policy.rs create mode 100644 grasp-audit/src/specs/grasp01/mod.rs create mode 100644 grasp-audit/src/specs/grasp01/nip01_smoke.rs create mode 100644 grasp-audit/src/specs/grasp01/nip11_document.rs delete mode 100644 grasp-audit/src/specs/grasp01_nostr_relay.rs delete mode 100644 grasp-audit/src/specs/nip01_smoke.rs (limited to 'grasp-audit/src') diff --git a/grasp-audit/src/client.rs b/grasp-audit/src/client.rs index cbefeb9..aed3058 100644 --- a/grasp-audit/src/client.rs +++ b/grasp-audit/src/client.rs @@ -232,6 +232,82 @@ impl AuditClient { Ok(event) } + + /// Create an issue (kind 1621) that references a repository + /// + /// # Arguments + /// * `repo_event` - The repository announcement event to reference + /// * `issue_title` - The subject/title of the issue + /// * `content` - The issue content/description + /// * `additional_tags` - Optional additional tags (e.g., for quoting other events) + /// + /// # Returns + /// A built and signed Event ready to be sent to the relay + pub fn create_issue( + &self, + repo_event: &Event, + issue_title: &str, + content: &str, + additional_tags: Vec, + ) -> Result { + // Extract repo_id from the d tag + let repo_id = repo_event.tags.iter() + .find(|t| t.kind() == TagKind::d()) + .and_then(|t| t.content()) + .ok_or_else(|| anyhow!("Repository event must have a 'd' tag"))? + .to_string(); + + let repo_pubkey = repo_event.pubkey; + let a_tag_value = format!("30617:{}:{}", repo_pubkey, repo_id); + + let mut tags = vec![ + Tag::custom(TagKind::custom("a"), vec![a_tag_value]), + Tag::custom(TagKind::custom("subject"), vec![issue_title]), + ]; + + // Add any additional tags + tags.extend(additional_tags); + + self.event_builder(Kind::Custom(1621), content) + .tags(tags) + .build(self.keys()) + .map_err(|e| anyhow!("Failed to build issue event: {}", e)) + } + + /// Create a NIP-22 comment (kind 1111) for an event + /// + /// # Arguments + /// * `event` - The event to comment on + /// * `content` - The comment content + /// * `additional_tags` - Optional additional tags + /// + /// # Returns + /// A built and signed Event ready to be sent to the relay + pub fn create_comment( + &self, + event: &Event, + content: &str, + additional_tags: Vec, + ) -> Result { + let event_kind = event.kind; + let event_pubkey = event.pubkey; + let event_id = event.id; + + let mut tags = vec![ + Tag::custom(TagKind::custom("E"), vec![event_id.to_hex(), "".to_string(), "root".to_string()]), + Tag::event(event_id), + Tag::custom(TagKind::custom("K"), vec![event_kind.as_u16().to_string()]), + Tag::public_key(event_pubkey), + ]; + + // Add any additional tags + tags.extend(additional_tags); + + self.event_builder(Kind::Custom(1111), content) + .tags(tags) + .build(self.keys()) + .map_err(|e| anyhow!("Failed to build comment event: {}", e)) + } } #[cfg(test)] diff --git a/grasp-audit/src/specs/grasp01/event_acceptance_policy.rs b/grasp-audit/src/specs/grasp01/event_acceptance_policy.rs new file mode 100644 index 0000000..9294b50 --- /dev/null +++ b/grasp-audit/src/specs/grasp01/event_acceptance_policy.rs @@ -0,0 +1,995 @@ +//! GRASP-01 Nostr Event Acceptance Policy +//! +//! Tests for GRASP-01 Nostr event acceptance policy (lines 3-7 of ../grasp/01.md) +//! +//! This file validates that a GRASP-01 compliant relay: +//! - Accepts valid NIP-34 repository announcements listing the service +//! - Rejects announcements that don't list the service in clone and relays tags +//! - Accepts repository state announcements +//! - Accepts events that TAG accepted repositories +//! - Accepts events that ARE TAGGED BY accepted events (transitive) +//! +//! ## Running Tests +//! +//! ### Recommended: Automated Relay Management +//! ```bash +//! cd grasp-audit && nix develop -c bash test-ngit-relay.sh --mode test +//! ``` +//! This script automatically starts a relay, runs all tests, and cleans up. +//! +//! ### Manual Testing (if needed) +//! ```bash +//! # 1. Start ngit-relay in a separate terminal: +//! docker run --rm -p 18081:8081 ghcr.io/danconwaydev/ngit-relay:latest +//! +//! # 2. Run all ignored tests (includes these smoke tests): +//! RELAY_URL="ws://localhost:18081" nix develop -c cargo test --lib -- --ignored --nocapture +//! +//! # 3. Run ONLY these specific smoke tests: +//! RELAY_URL="ws://localhost:18081" nix develop -c cargo test --lib test_grasp01_event_acceptance -- --ignored --nocapture +//! ``` +//! +//! ## Test Groups (12 total tests, target <5s execution) +//! +//! ### Group 1: Accept Events Tagging Accepted Repositories (3 tests) +//! - **Test 1.1**: Issue via `a` tag → Validates NIP-33 addressable event references +//! - **Test 1.2**: Comment via `A` tag → Validates NIP-22 root addressable references +//! - **Test 1.3**: Kind 1 via `q` tag → Validates NIP-18 quote references +//! +//! ### Group 2: Accept Events Tagging Accepted Events - Transitive (3 tests) +//! - **Test 2.1**: Issue quoting issue via `q` → Multi-hop transitive acceptance +//! - **Test 2.2**: Comment via `E` tag → NIP-22 threaded root references +//! - **Test 2.3**: Kind 1 via `e` tag → Standard NIP-01 reply chains +//! +//! ### Group 3: Accept Events Tagged BY Accepted Events - Forward References (3 tests) +//! - **Test 3.1**: Kind 1 referenced in issue → Forward reference acceptance +//! - **Test 3.2**: Comment referenced in comment → Nested forward references +//! - **Test 3.3**: Kind 1 referenced in Kind 1 → Cross-event forward refs +//! +//! ### Group 4: Reject Unrelated Events (3 tests) +//! - **Test 4.1**: Orphan issue → No repo connection +//! - **Test 4.2**: Orphan Kind 1 → No accepted event references +//! - **Test 4.3**: Comment quoting other repo → Wrong repository context +//! +//! ## Test Coverage Summary +//! +//! **Tag Types Validated:** +//! - `a` tags (NIP-33 addressable events) +//! - `A` tags (NIP-22 root addressable) +//! - `q` tags (NIP-18 quotes) +//! - `e` tags (NIP-01 event references) +//! - `E` tags (NIP-22 root event references) +//! +//! **Acceptance Paths:** +//! - Direct repository references (tags accepted repos) +//! - Transitive acceptance (tags events that tag accepted repos) +//! - Forward references (late-arriving events tagged by accepted events) +//! - Rejection cases (unrelated events with no connection) +//! +//! **Helper Functions (6 total):** +//! - `extract_d_tag()` - Extract identifier from events +//! - `create_test_repo()` - Create repository announcements +//! - `create_issue_for_repo()` - Create issues referencing repos +//! - `create_comment_for_event()` - Create NIP-22 comments +//! - `send_and_verify_accepted()` - Verify event acceptance +//! - `send_and_verify_rejected()` - Verify event rejection +//! +//! ## Performance Target +//! +//! All 12 tests should complete in **under 5 seconds** total when run against +//! a local ngit-relay instance. Each test includes a 100ms sleep for relay +//! propagation, so total theoretical minimum is ~1.2s for serial execution. +//! +//! ## Implementation Notes +//! +//! - Tests use the audit client which automatically adds cleanup tags +//! - All events are tagged for production relay cleanup +//! - Tests are designed to be independent and can run in any order +//! - Forward reference tests verify out-of-order event acceptance +//! - Transitive tests verify multi-hop acceptance chains + +use nostr_sdk::{Event, Filter, Kind, Tag, TagKind, Timestamp}; +use crate::{AuditClient, AuditResult, TestResult}; +use std::time::Duration; + +/// Test suite for GRASP-01 event acceptance policy +pub struct EventAcceptancePolicyTests; + +impl EventAcceptancePolicyTests { + /// Run all event acceptance policy tests + pub async fn run_all(client: &AuditClient) -> AuditResult { + let mut results = AuditResult::new("GRASP-01 Nostr Event Acceptance Policy Tests"); + + // Repository Announcement Acceptance Tests + results.add(Self::test_accept_valid_repo_announcement(client).await); + results.add(Self::test_reject_repo_announcement_missing_clone_tag(client).await); + results.add(Self::test_reject_repo_announcement_missing_relays_tag(client).await); + + // Repository State Announcement Tests + results.add(Self::test_accept_valid_repo_state_announcement(client).await); + + // Group 1: Accept Events Tagging Accepted Repositories + results.add(Self::test_accept_issue_via_a_tag(client).await); + results.add(Self::test_accept_comment_via_A_tag(client).await); + results.add(Self::test_accept_kind1_via_q_tag(client).await); + + // Group 2: Accept Events Tagging Accepted Events (Transitive) + results.add(Self::test_accept_issue_quoting_issue_via_q(client).await); + results.add(Self::test_accept_comment_via_E_tag(client).await); + results.add(Self::test_accept_kind1_via_e_tag(client).await); + + // Group 3: Accept Events Tagged BY Accepted Events (Forward Refs) + results.add(Self::test_accept_kind1_referenced_in_issue(client).await); + results.add(Self::test_accept_comment_referenced_in_comment(client).await); + results.add(Self::test_accept_kind1_referenced_in_kind1(client).await); + + // Group 4: Reject Unrelated Events + results.add(Self::test_reject_orphan_issue(client).await); + results.add(Self::test_reject_orphan_kind1(client).await); + results.add(Self::test_reject_comment_quoting_other_repo(client).await); + + results + } + + // ============================================================ + // Repository Announcement Acceptance Tests + // ============================================================ + + /// Test: Accept valid repository announcements + /// + /// Spec: Lines 3-5 of ../grasp/01.md + /// Requirement: MUST accept repo announcements listing service in clone & relays tags + async fn test_accept_valid_repo_announcement(client: &AuditClient) -> TestResult { + TestResult::new( + "accept_valid_repo_announcement", + "GRASP-01:nostr-relay:3-5", + "Accept valid repository announcements with service in clone and relays tags", + ) + .run(|| async { + // Create a NIP-34 repository announcement event + let event = client.create_repo_announcement("accept_valid_repo_announcement").await + .map_err(|e| format!("Failed to create repository announcement: {}", e))?; + + // Get relay URL for validation + let relay_url = client.client().relays().await + .keys() + .next() + .ok_or("No relay connected")? + .to_string(); + + // Convert WebSocket URL to HTTP URL for validation + let http_url = relay_url + .replace("ws://", "http://") + .replace("wss://", "https://"); + + // Extract repo_id from the event's d tag + let repo_id = event.tags.iter() + .find(|t| t.kind() == TagKind::d()) + .and_then(|t| t.content()) + .ok_or("Missing d tag in announcement")? + .to_string(); + + // Send the event + let event_id = client.send_event(event.clone()).await + .map_err(|e| format!("Failed to send repository announcement to relay: {}", e))?; + + // Query back to verify it was accepted and stored + let filter = Filter::new() + .kind(Kind::GitRepoAnnouncement) + .author(client.public_key()) + .identifier(&repo_id); + + let events = client.query(filter).await + .map_err(|e| format!("Failed to query events from relay: {}", e))?; + + // Verify we got the event back + if events.is_empty() { + return Err(format!( + "Event was not stored in relay (possibly rejected). Event ID: {}, Repo ID: {}", + event_id, repo_id + )); + } + + // Verify it's the same event + let stored_event = events.iter() + .find(|e| e.id == event_id) + .ok_or(format!( + "Stored event ID doesn't match sent event. Expected: {}, Got {} events", + event_id, events.len() + ))?; + + // Verify key tags are present + let has_clone_tag = stored_event.tags.iter() + .any(|t| { + t.kind() == TagKind::Custom("clone".into()) && + t.content().map(|c| c.contains(&http_url)).unwrap_or(false) + }); + + let has_relays_tag = stored_event.tags.iter() + .any(|t| { + t.kind() == TagKind::Custom("relays".into()) && + t.content() == Some(&relay_url) + }); + + if !has_clone_tag { + return Err(format!("Stored event missing clone tag with service URL ({})", http_url)); + } + + if !has_relays_tag { + return Err(format!("Stored event missing relays tag with service URL ({})", relay_url)); + } + + Ok(()) + }) + .await + } + + /// Test: Reject repo announcements not listing service in clone tag + /// + /// Spec: Line 5 of ../grasp/01.md + /// Requirement: MUST reject announcements not listing service (unless GRASP-05) + async fn test_reject_repo_announcement_missing_clone_tag(client: &AuditClient) -> TestResult { + TestResult::new( + "reject_repo_announcement_missing_clone_tag", + "GRASP-01:nostr-relay:5", + "Reject repository announcements without service in clone tag", + ) + .run(|| async { + // Get relay URL from client + let relay_url = client.client().relays().await + .keys() + .next() + .ok_or("No relay connected - client has no active relay connections")? + .to_string(); + + // Create unique repository identifier + let timestamp = Timestamp::now().as_u64(); + let repo_id = format!("test-repo-no-clone-{}", timestamp); + + // Create repo announcement WITHOUT service in clone tag + let event = client.event_builder(Kind::GitRepoAnnouncement, "") + .tag(Tag::identifier(&repo_id)) + .tag(Tag::custom(TagKind::Custom("name".into()), vec!["Test Repo No Clone"])) + .tag(Tag::custom(TagKind::Custom("clone".into()), vec!["https://github.com/user/repo.git"])) // NOT this service + .tag(Tag::custom(TagKind::Custom("relays".into()), vec![relay_url.clone()])) // Correct relay + .build(client.keys()) + .map_err(|e| format!("Failed to build event: {}", e))?; + + let event_id = event.id; + + // Send event - expect rejection + let send_result = client.send_event(event.clone()).await; + + // Query to verify event is NOT stored + let filter = Filter::new() + .kind(Kind::GitRepoAnnouncement) + .author(client.public_key()) + .identifier(&repo_id); + + let events = client.query(filter).await + .map_err(|e| format!("Failed to query events from relay: {}", e))?; + + // Verify event was rejected (not stored) + if events.iter().any(|e| e.id == event_id) { + return Err(format!( + "Relay incorrectly accepted announcement without service in clone tag. \ + Event ID: {}, Clone URL: https://github.com/user/repo.git (should require {})", + event_id, relay_url + )); + } + + Ok(()) + }) + .await + } + + /// Test: Reject repo announcements not listing service in relays tag + /// + /// Spec: Line 5 of ../grasp/01.md + /// Requirement: MUST reject announcements not listing service in relays + async fn test_reject_repo_announcement_missing_relays_tag(client: &AuditClient) -> TestResult { + TestResult::new( + "reject_repo_announcement_missing_relays_tag", + "GRASP-01:nostr-relay:5", + "Reject repository announcements without service in relays tag", + ) + .run(|| async { + // Get relay URL from client + let relay_url = client.client().relays().await + .keys() + .next() + .ok_or("No relay connected - client has no active relay connections")? + .to_string(); + + // Convert WebSocket URL to HTTP URL for clone tag + let http_url = relay_url + .replace("ws://", "http://") + .replace("wss://", "https://"); + + // Create unique repository identifier + let timestamp = Timestamp::now().as_u64(); + let repo_id = format!("test-repo-no-relays-{}", timestamp); + + // Create repo announcement WITHOUT service in relays tag + let event = client.event_builder(Kind::GitRepoAnnouncement, "") + .tag(Tag::identifier(&repo_id)) + .tag(Tag::custom(TagKind::custom("name"), vec!["Test Repo No Relays"])) + .tag(Tag::custom(TagKind::custom("clone"), vec![format!("{}/{}/test-repo.git", http_url, client.public_key())])) // Correct clone + .tag(Tag::custom(TagKind::custom("relays"), vec!["wss://relay.damus.io"])) // NOT this service + .build(client.keys()) + .map_err(|e| format!("Failed to build event: {}", e))?; + + let event_id = event.id; + + // Send event - expect rejection + let _send_result = client.send_event(event.clone()).await; + + // Query to verify event is NOT stored + let filter = Filter::new() + .kind(Kind::GitRepoAnnouncement) + .author(client.public_key()) + .identifier(&repo_id); + + let events = client.query(filter).await + .map_err(|e| format!("Failed to query events from relay: {}", e))?; + + // Verify event was rejected (not stored) + if events.iter().any(|e| e.id == event_id) { + return Err(format!( + "Relay incorrectly accepted announcement without service in relays tag. \ + Event ID: {}, Relays URL: wss://relay.damus.io (should require {})", + event_id, relay_url + )); + } + + Ok(()) + }) + .await + } + + // ============================================================ + // Repository State Announcement Tests + // ============================================================ + + /// Test: Accept valid repository state announcements + /// + /// Spec: Lines 6-7 of ../grasp/01.md + /// Requirement: MUST accept repo state announcements with d, maintainers, and r tags + async fn test_accept_valid_repo_state_announcement(client: &AuditClient) -> TestResult { + TestResult::new( + "accept_valid_repo_state_announcement", + "GRASP-01:nostr-relay:6-7", + "Accept valid state announcements after repo announcement accepted", + ) + .run(|| async { + // First, create a repository announcement (kind 30617) by the same author + let test_name = format!("test-repo-multi-refs-{}", Timestamp::now().as_u64()); + let repo_event = client.create_repo_announcement(&test_name).await + .map_err(|e| format!("Failed to create repository announcement: {}", e))?; + + // Extract repo_id from the repository announcement + let repo_id = repo_event.tags.iter() + .find(|t| t.kind() == TagKind::d()) + .and_then(|t| t.content()) + .ok_or("Missing d tag in repository announcement")? + .to_string(); + + // Note: npub not used in this test, removed unused variable + + // Create kind 30618 repository state announcement with multiple refs + // Format: ["r", "refs/heads/main", ""] + let event = client.event_builder(Kind::Custom(30618), "") + .tag(Tag::identifier(&repo_id)) + .tag(Tag::custom(TagKind::custom("refs/heads/main"), vec![ + "abc123def456789012345678901234567890abcd" + ])) + .tag(Tag::custom(TagKind::custom("refs/heads/develop"), vec![ + "def456789012345678901234567890abcdef123" + ])) + .tag(Tag::custom(TagKind::custom("refs/tags/v1.0.0"), vec![ + "123456789012345678901234567890abcdef456" + ])) + .tag(Tag::custom(TagKind::custom("HEAD"), vec![ + "ref: refs/heads/main" + ])) + .build(client.keys()) + .map_err(|e| format!("Failed to build state announcement: {}", e))?; + + let event_id = event.id; + + // Send the repo announcement event + client.send_event(repo_event.clone()).await + .map_err(|e| format!("Failed to send state announcement to relay: {}", e))?; + + // Send the state event + client.send_event(event.clone()).await + .map_err(|e| format!("Failed to send state announcement to relay: {}", e))?; + + // Query back to verify it was accepted and stored + let filter = Filter::new() + .kind(Kind::Custom(30618)) + .author(client.public_key()) + .identifier(&repo_id); + + let events = client.query(filter).await + .map_err(|e| format!("Failed to query events from relay: {}", e))?; + + // Verify we got the event back + if events.is_empty() { + return Err(format!( + "Event was not stored in relay (possibly rejected). Event ID: {}, Repo ID: {}", + event_id, repo_id + )); + } + + Ok(()) + }) + .await + } + + // ============================================================ + // Helper Functions (6 total) + // ============================================================ + + /// Extract the `d` tag value from an event + fn extract_d_tag(event: &Event) -> Option { + for tag in event.tags.iter() { + let tag_vec = tag.clone().to_vec(); + if tag_vec.len() >= 2 && tag_vec[0] == "d" { + return Some(tag_vec[1].to_string()); + } + } + None + } + + /// Create a basic repository announcement (kind 30617) + /// Uses the client's create_repo_announcement helper which includes required clone and relays tags + async fn create_test_repo(client: &AuditClient, repo_id: &str) -> Result { + client.create_repo_announcement(repo_id) + .await + .map_err(|e| e.to_string()) + } + + /// Create an issue (kind 1621) that references a repository + /// Uses AuditClient::create_issue helper method + async fn create_issue_for_repo( + client: &AuditClient, + repo_event: &Event, + issue_title: &str, + ) -> Result { + client.create_issue(repo_event, issue_title, "issue content", vec![]) + .map_err(|e| e.to_string()) + } + + /// Create a NIP-22 comment (kind 1111) for an event + /// Uses AuditClient::create_comment helper method + async fn create_comment_for_event( + client: &AuditClient, + event: &Event, + content: &str, + ) -> Result { + client.create_comment(event, content, vec![]) + .map_err(|e| e.to_string()) + } + + /// Send event and verify it was accepted (stored by relay) + async fn send_and_verify_accepted( + client: &AuditClient, + event: Event, + description: &str, + ) -> Result<(), String> { + let event_id = event.id; + + client.send_event(event).await + .map_err(|e| e.to_string())?; + + tokio::time::sleep(Duration::from_millis(100)).await; + + let filter = Filter::new().id(event_id); + let events = client.query(filter).await + .map_err(|e| e.to_string())?; + + if events.is_empty() { + return Err(format!("Event should be accepted: {}", description)); + } + + Ok(()) + } + + /// Send event and verify it was rejected (NOT stored by relay) + async fn send_and_verify_rejected( + client: &AuditClient, + event: Event, + description: &str, + ) -> Result<(), String> { + let event_id = event.id; + + client.send_event(event).await + .map_err(|e| e.to_string())?; + + tokio::time::sleep(Duration::from_millis(100)).await; + + let filter = Filter::new().id(event_id); + let events = client.query(filter).await + .map_err(|e| e.to_string())?; + + if !events.is_empty() { + return Err(format!("Event should be rejected: {}", description)); + } + + Ok(()) + } + + // ============================================================ + // Group 1: Accept Events Tagging Accepted Repositories (3 tests) + // ============================================================ + + /// Test 1.1: Issue referencing repo via `a` tag should be accepted + async fn test_accept_issue_via_a_tag(client: &AuditClient) -> TestResult { + TestResult::new( + "accept_issue_via_a_tag", + "GRASP-01:event-acceptance:1.1", + "Accept issue referencing repo via 'a' tag", + ) + .run(|| async { + // 1. Create and send repo announcement + let repo = Self::create_test_repo(client, "test-repo-1").await?; + Self::send_and_verify_accepted(client, repo.clone(), "repository announcement").await?; + + // 2. Create issue that references the repo (uses create_issue_for_repo helper) + let issue = Self::create_issue_for_repo(client, &repo, "Test Issue 1").await?; + + // 3. Send issue and verify it's accepted + Self::send_and_verify_accepted(client, issue, "issue referencing repo via 'a' tag").await?; + + Ok(()) + }) + .await + } + + /// Test 1.2: NIP-22 comment with root `A` tag referencing repo should be accepted + async fn test_accept_comment_via_A_tag(client: &AuditClient) -> TestResult { + TestResult::new( + "accept_comment_via_A_tag", + "GRASP-01:event-acceptance:1.2", + "Accept NIP-22 comment with root 'A' tag referencing repo", + ) + .run(|| async { + // 1. Create and send repo announcement + let repo = Self::create_test_repo(client, "test-repo-2").await?; + Self::send_and_verify_accepted(client, repo.clone(), "repository announcement").await?; + + // 2. Extract repo_id and create `A` tag manually + let repo_id = Self::extract_d_tag(&repo) + .ok_or("Failed to extract repo_id from repo event")?; + let a_tag_value = format!("30617:{}:{}", repo.pubkey, repo_id); + + // 3. Create comment with `A` tag (root reference to repo) + let tags = vec![ + Tag::custom(TagKind::custom("A"), vec![a_tag_value.clone(), "".to_string(), "root".to_string()]), + Tag::custom(TagKind::custom("K"), vec!["30617".to_string()]), + Tag::public_key(repo.pubkey), + ]; + + let comment = client + .event_builder(Kind::Custom(1111), "Comment on repo") + .tags(tags) + .build(client.keys()) + .map_err(|e| format!("Failed to build comment: {}", e))?; + + // 4. Send comment and verify it's accepted + Self::send_and_verify_accepted(client, comment, "comment with 'A' tag to repo").await?; + + Ok(()) + }) + .await + } + + /// Test 1.3: Kind 1 text note quoting repo via `q` tag should be accepted + async fn test_accept_kind1_via_q_tag(client: &AuditClient) -> TestResult { + TestResult::new( + "accept_kind1_via_q_tag", + "GRASP-01:event-acceptance:1.3", + "Accept kind 1 note quoting repo via 'q' tag", + ) + .run(|| async { + // 1. Create and send repo announcement + let repo = Self::create_test_repo(client, "test-repo-3").await?; + Self::send_and_verify_accepted(client, repo.clone(), "repository announcement").await?; + + // 2. Extract repo_id and create `q` tag + let repo_id = Self::extract_d_tag(&repo) + .ok_or("Failed to extract repo_id from repo event")?; + let a_tag_value = format!("30617:{}:{}", repo.pubkey, repo_id); + + // 3. Create kind 1 note with `q` tag (quote reference to repo) + let tags = vec![ + Tag::custom(TagKind::custom("q"), vec![a_tag_value]), + ]; + + let note = client + .event_builder(Kind::TextNote, "Mentioning this repo") + .tags(tags) + .build(client.keys()) + .map_err(|e| format!("Failed to build note: {}", e))?; + + // 4. Send note and verify it's accepted + Self::send_and_verify_accepted(client, note, "kind 1 with 'q' tag to repo").await?; + + Ok(()) + }) + .await + } + + // ============================================================ + // Group 2: Accept Events Tagging Accepted Events (3 tests) + // ============================================================ + + /// Test 2.1: Issue quoting another accepted issue should be accepted (transitive) + async fn test_accept_issue_quoting_issue_via_q(client: &AuditClient) -> TestResult { + TestResult::new( + "accept_issue_quoting_issue_via_q", + "GRASP-01:event-acceptance:2.1", + "Accept issue quoting accepted issue (transitive)", + ) + .run(|| async { + + // 1. Create and send Repo A + let repo_a = Self::create_test_repo(client, "repo-a").await?; + Self::send_and_verify_accepted(client, repo_a.clone(), "repo A").await?; + + // 2. Create and send Issue A (references repo A, so it's accepted) + let issue_a = Self::create_issue_for_repo(client, &repo_a, "Issue A").await?; + Self::send_and_verify_accepted(client, issue_a.clone(), "issue A").await?; + + // 3. Create Repo B but DON'T send it (unaccepted) - just for creating Issue B + let repo_b = Self::create_test_repo(client, "repo-b").await?; + + // 4. Create Issue B that: + // - References unaccepted Repo B (would normally be rejected) + // - BUT also quotes accepted Issue A via 'q' tag (should make it accepted) + let additional_tags = vec![ + // Quote to accepted Issue A (this makes it transitive) + Tag::custom(TagKind::custom("q"), vec![issue_a.id.to_hex()]), + ]; + + let issue_b = client + .create_issue(&repo_b, "Issue B", "issue content", additional_tags) + .map_err(|e| format!("Failed to build issue B: {}", e))?; + + // 5. Send Issue B and verify it's ACCEPTED (via transitive quote to Issue A) + Self::send_and_verify_accepted(client, issue_b, "issue B quoting accepted issue A").await?; + + Ok(()) + }) + .await + } + + /// Test 2.2: NIP-22 comment with root 'E' tag to accepted issue should be accepted + async fn test_accept_comment_via_E_tag(client: &AuditClient) -> TestResult { + TestResult::new( + "accept_comment_via_E_tag", + "GRASP-01:event-acceptance:2.2", + "Accept NIP-22 comment with root 'E' tag to accepted issue", + ) + .run(|| async { + + // 1. Create and send repo + let repo = Self::create_test_repo(client, "repo-comment").await?; + Self::send_and_verify_accepted(client, repo.clone(), "repo").await?; + + // 2. Create and send issue (references repo, so it's accepted) + let issue = Self::create_issue_for_repo(client, &repo, "Issue for comment").await?; + Self::send_and_verify_accepted(client, issue.clone(), "issue").await?; + + // 3. Create comment using the helper (which adds NIP-22 tags including 'E') + let comment = Self::create_comment_for_event(client, &issue, "Comment content").await?; + + // 4. Send comment and verify it's accepted (via E tag to accepted issue) + Self::send_and_verify_accepted(client, comment, "comment with E tag to accepted issue").await?; + + Ok(()) + }) + .await + } + + /// Test 2.3: Kind 1 note with 'e' tag reply to accepted kind 1 should be accepted + async fn test_accept_kind1_via_e_tag(client: &AuditClient) -> TestResult { + TestResult::new( + "accept_kind1_via_e_tag", + "GRASP-01:event-acceptance:2.3", + "Accept kind 1 reply via 'e' tag to accepted kind 1", + ) + .run(|| async { + + // 1. Create and send repo + let repo = Self::create_test_repo(client, "repo-notes").await?; + Self::send_and_verify_accepted(client, repo.clone(), "repo").await?; + + // 2. Create Kind 1 A that quotes the repo (makes it accepted) + let repo_id = Self::extract_d_tag(&repo) + .ok_or("Failed to extract repo_id")?; + let a_tag_value = format!("30617:{}:{}", repo.pubkey, repo_id); + + let kind1_a = client + .event_builder(Kind::TextNote, "Note A about repo") + .tags(vec![Tag::custom(TagKind::custom("q"), vec![a_tag_value])]) + .build(client.keys()) + .map_err(|e| format!("Failed to build kind1 A: {}", e))?; + + Self::send_and_verify_accepted(client, kind1_a.clone(), "kind 1 A quoting repo").await?; + + // 3. Create Kind 1 B that replies to Kind 1 A via 'e' tag + let kind1_b = client + .event_builder(Kind::TextNote, "Reply to Note A") + .tags(vec![Tag::event(kind1_a.id)]) + .build(client.keys()) + .map_err(|e| format!("Failed to build kind1 B: {}", e))?; + + // 4. Send Kind 1 B and verify it's accepted (via 'e' tag to accepted kind 1 A) + Self::send_and_verify_accepted(client, kind1_b, "kind 1 B replying to accepted kind 1 A").await?; + + Ok(()) + }) + .await + } + + // ============================================================ + // Group 3: Accept Events Tagged BY Accepted Events (3 tests) + // ============================================================ + + /// Test 3.1: Kind 1 note should be accepted when referenced by an accepted issue (forward ref) + async fn test_accept_kind1_referenced_in_issue(client: &AuditClient) -> TestResult { + TestResult::new( + "accept_kind1_referenced_in_issue", + "GRASP-01:event-acceptance:3.1", + "Accept kind 1 referenced in accepted issue (forward ref)", + ) + .run(|| async { + + // 1. Create and send repo (this establishes the accepted context) + let repo = Self::create_test_repo(client, "repo-fwd-1").await?; + Self::send_and_verify_accepted(client, repo.clone(), "repo").await?; + + // 2. Create Kind 1 note locally but DON'T send it yet + let kind1_note = client + .event_builder(Kind::TextNote, "Note to be referenced") + .build(client.keys()) + .map_err(|e| format!("Failed to build kind1: {}", e))?; + + // 3. Create and send issue that QUOTES the unsent Kind 1 note + let issue_tags = vec![ + // Reference to accepted repo + Tag::custom(TagKind::custom("a"), vec![ + format!("30617:{}:{}", repo.pubkey, Self::extract_d_tag(&repo).unwrap()) + ]), + Tag::custom(TagKind::custom("subject"), vec!["Issue referencing kind1".to_string()]), + // Quote the Kind 1 that hasn't been sent yet + Tag::custom(TagKind::custom("q"), vec![kind1_note.id.to_hex()]), + ]; + + let issue = client + .event_builder(Kind::Custom(1621), "issue content") + .tags(issue_tags) + .build(client.keys()) + .map_err(|e| format!("Failed to build issue: {}", e))?; + + Self::send_and_verify_accepted(client, issue, "issue quoting unsent kind1").await?; + + // 4. NOW send the Kind 1 note - should be accepted because accepted issue quotes it + Self::send_and_verify_accepted(client, kind1_note, "kind1 note referenced by accepted issue").await?; + + Ok(()) + }) + .await + } + + /// Test 3.2: Comment should be accepted when referenced by another accepted comment (forward ref) + async fn test_accept_comment_referenced_in_comment(client: &AuditClient) -> TestResult { + TestResult::new( + "accept_comment_referenced_in_comment", + "GRASP-01:event-acceptance:3.2", + "Accept comment referenced in another accepted comment (forward ref)", + ) + .run(|| async { + + // 1. Create and send repo + let repo = Self::create_test_repo(client, "repo-fwd-2").await?; + Self::send_and_verify_accepted(client, repo.clone(), "repo").await?; + + // 2. Create and send issue (references repo, so it's accepted) + let issue = Self::create_issue_for_repo(client, &repo, "Issue for comments").await?; + Self::send_and_verify_accepted(client, issue.clone(), "issue").await?; + + // 3. Create Comment A locally but DON'T send it yet + let comment_a = Self::create_comment_for_event(client, &issue, "Comment A").await?; + + // 4. Create and send Comment B that quotes Comment A (which hasn't been sent) + let comment_b_tags = vec![ + // NIP-22 tags for the original issue + Tag::custom(TagKind::custom("E"), vec![issue.id.to_hex(), "".to_string(), "root".to_string()]), + Tag::event(issue.id), + Tag::custom(TagKind::custom("K"), vec![issue.kind.as_u16().to_string()]), + Tag::public_key(issue.pubkey), + // Quote Comment A which hasn't been sent yet + Tag::custom(TagKind::custom("q"), vec![comment_a.id.to_hex()]), + ]; + + let comment_b = client + .event_builder(Kind::Custom(1111), "Comment B quoting Comment A") + .tags(comment_b_tags) + .build(client.keys()) + .map_err(|e| format!("Failed to build comment B: {}", e))?; + + Self::send_and_verify_accepted(client, comment_b, "comment B quoting unsent comment A").await?; + + // 5. NOW send Comment A - should be accepted because accepted Comment B quotes it + Self::send_and_verify_accepted(client, comment_a, "comment A referenced by accepted comment B").await?; + + Ok(()) + }) + .await + } + + /// Test 3.3: Kind 1 note should be accepted when referenced by another accepted kind 1 (forward ref) + async fn test_accept_kind1_referenced_in_kind1(client: &AuditClient) -> TestResult { + TestResult::new( + "accept_kind1_referenced_in_kind1", + "GRASP-01:event-acceptance:3.3", + "Accept kind 1 referenced in another accepted kind 1 (forward ref)", + ) + .run(|| async { + // 1. Create and send repo + let repo = Self::create_test_repo(client, "repo-fwd-3").await?; + Self::send_and_verify_accepted(client, repo.clone(), "repo").await?; + + // 2. Create Kind 1 A locally but DON'T send it yet + let kind1_a = client + .event_builder(Kind::TextNote, "Note A to be referenced") + .build(client.keys()) + .map_err(|e| format!("Failed to build kind1 A: {}", e))?; + + // 3. Create and send Kind 1 B that: + // - Quotes the repo (makes it accepted) + // - Mentions Kind 1 A via 'e' tag (which hasn't been sent yet) + let repo_id = Self::extract_d_tag(&repo) + .ok_or("Failed to extract repo_id")?; + let a_tag_value = format!("30617:{}:{}", repo.pubkey, repo_id); + + let kind1_b = client + .event_builder(Kind::TextNote, "Note B mentioning Note A") + .tags(vec![ + Tag::custom(TagKind::custom("q"), vec![a_tag_value]), // Quote repo (accepted) + Tag::event(kind1_a.id), // Mention unsent Kind 1 A + ]) + .build(client.keys()) + .map_err(|e| format!("Failed to build kind1 B: {}", e))?; + + Self::send_and_verify_accepted(client, kind1_b, "kind1 B mentioning unsent kind1 A").await?; + + // 4. NOW send Kind 1 A - should be accepted because accepted Kind 1 B mentions it + Self::send_and_verify_accepted(client, kind1_a, "kind1 A referenced by accepted kind1 B").await?; + + Ok(()) + }) + .await + } + + // ============================================================ + // Group 4: Reject Unrelated Events (3 tests) + // ============================================================ + + /// Test 4.1: Issue referencing unaccepted repo should be rejected + async fn test_reject_orphan_issue(client: &AuditClient) -> TestResult { + TestResult::new( + "reject_orphan_issue", + "GRASP-01:event-acceptance:4.1", + "Reject issue referencing unaccepted repo", + ) + .run(|| async { + // 1. Create a repo but DON'T send it (so it's unaccepted) + let unaccepted_repo = Self::create_test_repo(client, "unaccepted-repo-1").await?; + + // 2. Create issue that references the unaccepted repo + let orphan_issue = Self::create_issue_for_repo(client, &unaccepted_repo, "Orphan Issue").await?; + + // 3. Send issue and verify it's REJECTED + Self::send_and_verify_rejected(client, orphan_issue, "issue referencing unaccepted repo").await?; + + Ok(()) + }) + .await + } + + /// Test 4.2: Generic kind 1 note with no repo references should be rejected + async fn test_reject_orphan_kind1(client: &AuditClient) -> TestResult { + TestResult::new( + "reject_orphan_kind1", + "GRASP-01:event-acceptance:4.2", + "Reject kind 1 with no repo references", + ) + .run(|| async { + // 1. Create a kind 1 note with no tags (no repo references) + let orphan_note = client + .event_builder(Kind::TextNote, "Just a random note") + .build(client.keys()) + .map_err(|e| format!("Failed to build note: {}", e))?; + + // 2. Send note and verify it's REJECTED + Self::send_and_verify_rejected(client, orphan_note, "kind 1 with no repo references").await?; + + Ok(()) + }) + .await + } + + /// Test 4.3: Comment quoting unaccepted repo should be rejected + async fn test_reject_comment_quoting_other_repo(client: &AuditClient) -> TestResult { + TestResult::new( + "reject_comment_quoting_other_repo", + "GRASP-01:event-acceptance:4.3", + "Reject comment quoting unaccepted repo", + ) + .run(|| async { + // 1. Create and send Repo A (this one IS accepted) + let repo_a = Self::create_test_repo(client, "accepted-repo-a").await?; + Self::send_and_verify_accepted(client, repo_a.clone(), "repo A").await?; + + // 2. Create Repo B but DON'T send it (unaccepted) + let repo_b = Self::create_test_repo(client, "unaccepted-repo-b").await?; + + // 3. Extract repo_b info and create comment that quotes repo B (not repo A) + let repo_b_id = Self::extract_d_tag(&repo_b) + .ok_or("Failed to extract repo_b id")?; + let repo_b_a_tag = format!("30617:{}:{}", repo_b.pubkey, repo_b_id); + + // 4. Create comment that references ONLY repo B (unaccepted) + let tags = vec![ + Tag::custom(TagKind::custom("A"), vec![repo_b_a_tag, "".to_string(), "root".to_string()]), + Tag::custom(TagKind::custom("K"), vec!["30617".to_string()]), + Tag::public_key(repo_b.pubkey), + ]; + + let comment = client + .event_builder(Kind::Custom(1111), "Comment on unaccepted repo") + .tags(tags) + .build(client.keys()) + .map_err(|e| format!("Failed to build comment: {}", e))?; + + // 5. Send comment and verify it's REJECTED (only references unaccepted repo B) + Self::send_and_verify_rejected(client, comment, "comment quoting only unaccepted repo").await?; + + Ok(()) + }) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::AuditConfig; + + #[tokio::test] + #[ignore] // Requires running relay + async fn test_grasp01_event_acceptance_policy_against_relay() { + // Read relay URL from environment variable - must be supplied + let relay_url = std::env::var("RELAY_URL") + .expect("RELAY_URL environment variable must be set. Example: RELAY_URL=ws://localhost:18081"); + + let config = AuditConfig::ci(); + let client = AuditClient::new(&relay_url, config) + .await + .expect(&format!( + "Failed to connect to relay at {}. Ensure relay is running and accessible. \ + Try: docker run --rm -p 18081:8081 ghcr.io/danconwaydev/ngit-relay:latest", + relay_url + )); + + let results = EventAcceptancePolicyTests::run_all(&client).await; + results.print_report(); + + // Don't assert all passed yet - some tests may be failing + // Future: assert!(results.all_passed(), "Some GRASP-01 event acceptance tests failed"); + } +} \ No newline at end of file diff --git a/grasp-audit/src/specs/grasp01/mod.rs b/grasp-audit/src/specs/grasp01/mod.rs new file mode 100644 index 0000000..4f4583e --- /dev/null +++ b/grasp-audit/src/specs/grasp01/mod.rs @@ -0,0 +1,9 @@ +//! GRASP-01 specification tests + +pub mod event_acceptance_policy; +pub mod nip01_smoke; +pub mod nip11_document; + +pub use event_acceptance_policy::EventAcceptancePolicyTests; +pub use nip01_smoke::Nip01SmokeTests; +pub use nip11_document::Nip11DocumentTests; \ No newline at end of file diff --git a/grasp-audit/src/specs/grasp01/nip01_smoke.rs b/grasp-audit/src/specs/grasp01/nip01_smoke.rs new file mode 100644 index 0000000..9ed0f56 --- /dev/null +++ b/grasp-audit/src/specs/grasp01/nip01_smoke.rs @@ -0,0 +1,311 @@ +//! NIP-01 Smoke Tests +//! +//! These tests verify basic Nostr relay functionality. +//! We don't comprehensively test NIP-01 because rust-nostr already has 1000+ tests. +//! These are just smoke tests to ensure the relay is working at all. + +use crate::{AuditClient, AuditResult, TestResult}; +use nostr_sdk::prelude::*; + +pub struct Nip01SmokeTests; + +impl Nip01SmokeTests { + /// Run all NIP-01 smoke tests + pub async fn run_all(client: &AuditClient) -> AuditResult { + let mut results = AuditResult::new("NIP-01 Smoke Tests"); + + // Run tests sequentially to avoid future type issues + results.add(Self::test_websocket_connection(client).await); + results.add(Self::test_send_receive_event(client).await); + results.add(Self::test_create_subscription(client).await); + results.add(Self::test_close_subscription(client).await); + results.add(Self::test_reject_invalid_signature(client).await); + results.add(Self::test_reject_invalid_event_id(client).await); + + results + } + + /// Test 1: Can establish WebSocket connection + /// + /// Spec: NIP-01 basic requirement + /// Requirement: MUST serve a relay at / via WebSocket + async fn test_websocket_connection(client: &AuditClient) -> TestResult { + TestResult::new( + "websocket_connection", + "NIP-01:basic", + "Can establish WebSocket connection to /", + ) + .run(|| async { + if !client.is_connected().await { + return Err("Failed to connect to relay".to_string()); + } + + Ok(()) + }) + .await + } + + /// Test 2: Can send EVENT and receive OK response + /// + /// Spec: NIP-01 EVENT message + /// Requirement: Relay MUST accept valid EVENT messages + /// + /// For GRASP servers, we send a NIP-34 repository announcement that lists + /// the GRASP server in clone and relays tags (required for acceptance). + async fn test_send_receive_event(client: &AuditClient) -> TestResult { + TestResult::new( + "send_receive_event", + "NIP-01:event-message", + "Can send EVENT and receive OK response", + ) + .run(|| async { + // Create a NIP-34 announcement event + let event = client.create_repo_announcement("send_receive_event").await + .map_err(|e| format!("Failed to create announcement: {}", e))?; + + // Send event + let event_id = client + .send_event(event.clone()) + .await + .map_err(|e| format!("Failed to send event: {}", e))?; + + // Verify we got an event ID back + if event_id != event.id { + return Err(format!( + "Event ID mismatch: sent {}, got {}", + event.id, event_id + )); + } + + // Wait a bit for event to be indexed + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + + // Try to query it back + let filter = Filter::new() + .kind(Kind::Custom(30617)) + .id(event_id); + + let events = client + .query(filter) + .await + .map_err(|e| format!("Failed to query event: {}", e))?; + + if events.is_empty() { + // Debug: try querying without audit client filtering + eprintln!("Event not found with audit client query, trying direct client query..."); + let direct_filter = Filter::new().kind(Kind::Custom(30617)).id(event_id); + let direct_events = client.client().fetch_events(direct_filter, std::time::Duration::from_secs(5)).await + .map_err(|e| format!("Direct query failed: {}", e))?; + let direct_vec: Vec = direct_events.into_iter().collect(); + eprintln!("Direct query found {} events", direct_vec.len()); + if !direct_vec.is_empty() { + eprintln!("Event tags: {:?}", direct_vec[0].tags); + } + return Err(format!("Event not found after sending (direct query found {})", direct_vec.len())); + } + + if events[0].id != event_id { + return Err("Retrieved event has different ID".to_string()); + } + + Ok(()) + }) + .await + } + + /// Test 3: Can create subscription with REQ + /// + /// Spec: NIP-01 REQ message + /// Requirement: Relay MUST support REQ subscriptions + async fn test_create_subscription(client: &AuditClient) -> TestResult { + TestResult::new( + "create_subscription", + "NIP-01:req-message", + "Can create subscription with REQ and receive EOSE", + ) + .run(|| async { + // Create a NIP-34 announcement event (accepted by GRASP relays) + let event = client.create_repo_announcement("create_subscription").await + .map_err(|e| format!("Failed to create announcement: {}", e))?; + + let event_id = client + .send_event(event.clone()) + .await + .map_err(|e| format!("Failed to send event: {}", e))?; + + // Subscribe to NIP-34 announcements from this author + let filter = Filter::new() + .kind(Kind::Custom(30617)) + .author(client.public_key()); + + let events = client + .subscribe(vec![filter], Some(std::time::Duration::from_secs(5))) + .await + .map_err(|e| format!("Failed to subscribe: {}", e))?; + + // Should have at least our event + if events.is_empty() { + return Err("No events received from subscription".to_string()); + } + + Ok(()) + }) + .await + } + + /// Test 4: Can close subscription with CLOSE + /// + /// Spec: NIP-01 CLOSE message + /// Requirement: Relay MUST support CLOSE to end subscriptions + async fn test_close_subscription(client: &AuditClient) -> TestResult { + TestResult::new( + "close_subscription", + "NIP-01:close-message", + "Can close subscriptions", + ) + .run(|| async { + // For now, we just verify we can query events + // Full subscription management with CLOSE would require + // lower-level WebSocket access + + let filter = Filter::new() + .kind(Kind::TextNote) + .limit(1); + + let _events = client + .subscribe(vec![filter], Some(std::time::Duration::from_secs(2))) + .await + .map_err(|e| format!("Failed to subscribe: {}", e))?; + + // If we got here, subscription worked + Ok(()) + }) + .await + } + + /// Test 5: Rejects events with invalid signatures + /// + /// Spec: NIP-01 event validation + /// Requirement: Relay MUST reject events with invalid signatures + async fn test_reject_invalid_signature(client: &AuditClient) -> TestResult { + TestResult::new( + "reject_invalid_signature", + "NIP-01:validation", + "Rejects events with invalid signatures", + ) + .run(|| async { + // Create a valid event + let event = client + .event_builder(Kind::TextNote, "Invalid signature test") + .build(client.keys()) + .map_err(|e| format!("Failed to build event: {}", e))?; + + // Corrupt the signature by creating a new event with wrong sig + // We'll use a different key to sign, creating an invalid signature + let wrong_keys = Keys::generate(); + let wrong_event = EventBuilder::new(event.kind, event.content.clone()) + .tags(event.tags.clone()) + .sign_with_keys(&wrong_keys) + .map_err(|e| format!("Failed to build wrong event: {}", e))?; + + // Create event JSON with mismatched pubkey and signature + // This should be rejected by the relay + let invalid_event_json = serde_json::json!({ + "id": event.id.to_hex(), + "pubkey": event.pubkey.to_hex(), + "created_at": event.created_at.as_u64(), + "kind": event.kind.as_u16(), + "tags": event.tags, + "content": event.content, + "sig": wrong_event.sig.to_string(), // Wrong signature! + }); + + // Parse it back to an Event + let invalid_event: Event = serde_json::from_value(invalid_event_json) + .map_err(|e| format!("Failed to create invalid event: {}", e))?; + + // Try to send the invalid event + let result = client.send_event(invalid_event).await; + + // We expect this to fail + if result.is_ok() { + return Err("Relay accepted event with invalid signature".to_string()); + } + + Ok(()) + }) + .await + } + + /// Test 6: Rejects events with invalid event IDs + /// + /// Spec: NIP-01 event ID validation + /// Requirement: Relay MUST reject events where ID doesn't match hash + async fn test_reject_invalid_event_id(client: &AuditClient) -> TestResult { + TestResult::new( + "reject_invalid_event_id", + "NIP-01:validation", + "Rejects events with invalid event IDs", + ) + .run(|| async { + // Create a valid event + let event = client + .event_builder(Kind::TextNote, "Invalid ID test") + .build(client.keys()) + .map_err(|e| format!("Failed to build event: {}", e))?; + + // Create event JSON with corrupted ID + let invalid_event_json = serde_json::json!({ + "id": EventId::all_zeros().to_hex(), // Wrong ID! + "pubkey": event.pubkey.to_hex(), + "created_at": event.created_at.as_u64(), + "kind": event.kind.as_u16(), + "tags": event.tags, + "content": event.content, + "sig": event.sig.to_string(), + }); + + // Parse it back to an Event + let invalid_event: Event = serde_json::from_value(invalid_event_json) + .map_err(|e| format!("Failed to create invalid event: {}", e))?; + + // Try to send the invalid event + let result = client.send_event(invalid_event).await; + + // We expect this to fail + if result.is_ok() { + return Err("Relay accepted event with invalid ID".to_string()); + } + + Ok(()) + }) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::AuditConfig; + + // Note: These tests require a running relay + // They are integration tests, not unit tests + + #[tokio::test] + #[ignore] // Ignore by default since it needs a running relay + async fn test_smoke_tests_against_relay() { + // RELAY_URL env var must be set - no default fallback + let relay_url = std::env::var("RELAY_URL") + .expect("RELAY_URL environment variable must be set for integration tests"); + + let config = AuditConfig::ci(); + let client = AuditClient::new(&relay_url, config) + .await + .expect("Failed to connect to relay"); + + let results = Nip01SmokeTests::run_all(&client).await; + results.print_report(); + + assert!(results.all_passed(), "Some smoke tests failed"); + } +} diff --git a/grasp-audit/src/specs/grasp01/nip11_document.rs b/grasp-audit/src/specs/grasp01/nip11_document.rs new file mode 100644 index 0000000..3f9c04a --- /dev/null +++ b/grasp-audit/src/specs/grasp01/nip11_document.rs @@ -0,0 +1,165 @@ +//! GRASP-01 NIP-11 Document +//! +//! Tests for GRASP-01 NIP-11 relay information document requirements (lines 11-14 of ../grasp/01.md) +//! +//! These tests validate that a GRASP-01 compliant relay: +//! - Serves a valid NIP-11 relay information document +//! - Includes supported_grasps field listing supported GRASPs +//! - Includes repo_acceptance_criteria field describing acceptance policy +//! - Handles curation field correctly (present if curated, absent otherwise) + +use crate::{AuditClient, AuditResult, TestResult}; +use nostr_sdk::prelude::*; + +pub struct Nip11DocumentTests; + +impl Nip11DocumentTests { + /// Run all NIP-11 document tests + pub async fn run_all(client: &AuditClient) -> AuditResult { + let mut results = AuditResult::new("GRASP-01 NIP-11 Document Tests"); + + // NIP-11 relay information tests + results.add(Self::test_nip11_document_exists(client).await); + results.add(Self::test_nip11_supported_grasps_field(client).await); + results.add(Self::test_nip11_repo_acceptance_criteria_field(client).await); + results.add(Self::test_nip11_curation_field(client).await); + + results + } + + // ========================================================================= + // NIP-11 Relay Information Tests + // ========================================================================= + + /// Test: Serve NIP-11 document + /// + /// Spec: Line 11 of ../grasp/01.md + /// Requirement: MUST serve NIP-11 document + async fn test_nip11_document_exists(client: &AuditClient) -> TestResult { + TestResult::new( + "nip11_document_exists", + "GRASP-01:nostr-relay:11", + "Serve NIP-11 relay information document", + ) + .run(|| async { + // TODO: Implementation + // 1. Extract HTTP(S) URL from client's WebSocket URL + // - ws://localhost:8081 -> http://localhost:8081 + // - wss://relay.example.com -> https://relay.example.com + // 2. HTTP GET to base URL with header: + // - Accept: application/nostr+json + // 3. Verify 200 OK response + // 4. Verify response is valid JSON + // 5. Parse as NIP-11 document + // 6. Verify has required fields (name, description, etc.) + + Err("Not implemented yet".to_string()) + }) + .await + } + + /// Test: NIP-11 includes supported_grasps field + /// + /// Spec: Line 12 of ../grasp/01.md + /// Requirement: MUST list supported GRASPs as string array + async fn test_nip11_supported_grasps_field(client: &AuditClient) -> TestResult { + TestResult::new( + "nip11_supported_grasps_field", + "GRASP-01:nostr-relay:12", + "NIP-11 document includes supported_grasps field with GRASP-01", + ) + .run(|| async { + // TODO: Implementation + // 1. Fetch NIP-11 document (same as above) + // 2. Verify `supported_grasps` field exists + // 3. Verify it's a JSON array of strings + // 4. Verify array includes "GRASP-01" + // 5. Verify format: each entry matches pattern "GRASP-\d{2}" + // 6. Document other GRASPs found (for info) + + Err("Not implemented yet".to_string()) + }) + .await + } + + /// Test: NIP-11 includes repo_acceptance_criteria field + /// + /// Spec: Line 13 of ../grasp/01.md + /// Requirement: MUST list repository acceptance criteria + async fn test_nip11_repo_acceptance_criteria_field(client: &AuditClient) -> TestResult { + TestResult::new( + "nip11_repo_acceptance_criteria_field", + "GRASP-01:nostr-relay:13", + "NIP-11 document includes repo_acceptance_criteria field", + ) + .run(|| async { + // TODO: Implementation + // 1. Fetch NIP-11 document + // 2. Verify `repo_acceptance_criteria` field exists + // 3. Verify it's a string (human-readable) + // 4. Verify non-empty + // 5. Document the criteria (for info) + // Examples: "Must list this relay in clone and relays tags" + // "Pre-payment required via Lightning invoice" + + Err("Not implemented yet".to_string()) + }) + .await + } + + /// Test: NIP-11 curation field handling + /// + /// Spec: Line 14 of ../grasp/01.md + /// Requirement: MUST include curation if curated, omit otherwise + async fn test_nip11_curation_field(client: &AuditClient) -> TestResult { + TestResult::new( + "nip11_curation_field", + "GRASP-01:nostr-relay:14", + "NIP-11 curation field present if curated, absent otherwise", + ) + .run(|| async { + // TODO: Implementation + // 1. Fetch NIP-11 document + // 2. Check if `curation` field exists + // 3. If present: + // - Verify it's a non-empty string + // - Document the curation policy + // 4. If absent: + // - Document that no curation beyond SPAM prevention + // 5. Both cases are valid per spec + + Err("Not implemented yet".to_string()) + }) + .await + } + +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::AuditConfig; + +#[tokio::test] +#[ignore] // Requires running relay +async fn test_grasp01_nip11_document_against_relay() { + // Read relay URL from environment variable - must be supplied + let relay_url = std::env::var("RELAY_URL") + .expect("RELAY_URL environment variable must be set. Example: RELAY_URL=ws://localhost:18081"); + + let config = AuditConfig::ci(); + let client = AuditClient::new(&relay_url, config) + .await + .expect(&format!( + "Failed to connect to relay at {}. Ensure relay is running and accessible. \ + Try: docker run --rm -p 18081:8081 ghcr.io/danconwaydev/ngit-relay:latest", + relay_url + )); + + let results = Nip11DocumentTests::run_all(&client).await; + results.print_report(); + + // Don't assert all passed yet - tests not implemented + // assert!(results.all_passed(), "Some GRASP-01 NIP-11 document tests failed"); + } +} \ No newline at end of file diff --git a/grasp-audit/src/specs/grasp01_nostr_relay.rs b/grasp-audit/src/specs/grasp01_nostr_relay.rs deleted file mode 100644 index 247850b..0000000 --- a/grasp-audit/src/specs/grasp01_nostr_relay.rs +++ /dev/null @@ -1,717 +0,0 @@ -//! GRASP-01 Nostr Relay Tests -//! -//! Tests for GRASP-01 Nostr relay requirements (lines 1-14 of ../grasp/01.md) -//! -//! These tests validate that a GRASP-01 compliant relay: -//! - Accepts valid NIP-34 repository announcements and state announcements -//! - Rejects announcements that don't list the service -//! - Accepts related events (issues, patches, PRs) -//! - Serves proper NIP-11 relay information document - -use crate::{AuditClient, AuditResult, TestResult}; -use nostr_sdk::prelude::*; - -pub struct Grasp01NostrRelayTests; - -impl Grasp01NostrRelayTests { - /// Run all GRASP-01 Nostr relay tests - pub async fn run_all(client: &AuditClient) -> AuditResult { - let mut results = AuditResult::new("GRASP-01 Nostr Relay Tests"); - - // Repository announcement acceptance tests - results.add(Self::test_accept_valid_repo_announcement(client).await); - results.add(Self::test_reject_repo_announcement_missing_clone_tag(client).await); - results.add(Self::test_reject_repo_announcement_missing_relays_tag(client).await); - - // Repository state announcement tests - results.add(Self::test_accept_valid_repo_state_announcement(client).await); - - // Related event acceptance tests - results.add(Self::test_accept_event_tagging_repo_announcement(client).await); - results.add(Self::test_accept_event_tagged_by_repo(client).await); - results.add(Self::test_accept_patch_for_repo(client).await); - results.add(Self::test_accept_pull_request_for_repo(client).await); - results.add(Self::test_accept_issue_for_repo(client).await); - results.add(Self::test_accept_reply_to_issue(client).await); - - // NIP-11 relay information tests - results.add(Self::test_nip11_document_exists(client).await); - results.add(Self::test_nip11_supported_grasps_field(client).await); - results.add(Self::test_nip11_repo_acceptance_criteria_field(client).await); - results.add(Self::test_nip11_curation_field(client).await); - - // Policy tests (document behavior) - results.add(Self::test_custom_rejection_allowed(client).await); - results.add(Self::test_spam_prevention_allowed(client).await); - - results - } - - // ========================================================================= - // Repository Announcement Acceptance Tests - // ========================================================================= - - /// Test: Accept valid repository announcements - /// - /// Spec: Lines 3-5 of ../grasp/01.md - /// Requirement: MUST accept repo announcements listing service in clone & relays tags - async fn test_accept_valid_repo_announcement(client: &AuditClient) -> TestResult { - TestResult::new( - "accept_valid_repo_announcement", - "GRASP-01:nostr-relay:3-5", - "Accept valid repository announcements with service in clone and relays tags", - ) - .run(|| async { - // Create a NIP-34 repository announcement event - let event = client.create_repo_announcement("accept_valid_repo_announcement").await - .map_err(|e| format!("Failed to create repository announcement: {}", e))?; - - // Get relay URL for validation - let relay_url = client.client().relays().await - .keys() - .next() - .ok_or("No relay connected")? - .to_string(); - - // Convert WebSocket URL to HTTP URL for validation - let http_url = relay_url - .replace("ws://", "http://") - .replace("wss://", "https://"); - - // Extract repo_id from the event's d tag - let repo_id = event.tags.iter() - .find(|t| t.kind() == TagKind::d()) - .and_then(|t| t.content()) - .ok_or("Missing d tag in announcement")? - .to_string(); - - // Send the event - let event_id = client.send_event(event.clone()).await - .map_err(|e| format!("Failed to send repository announcement to relay: {}", e))?; - - // Query back to verify it was accepted and stored - let filter = Filter::new() - .kind(Kind::GitRepoAnnouncement) - .author(client.public_key()) - .identifier(&repo_id); - - let events = client.query(filter).await - .map_err(|e| format!("Failed to query events from relay: {}", e))?; - - // Verify we got the event back - if events.is_empty() { - return Err(format!( - "Event was not stored in relay (possibly rejected). Event ID: {}, Repo ID: {}", - event_id, repo_id - )); - } - - // Verify it's the same event - let stored_event = events.iter() - .find(|e| e.id == event_id) - .ok_or(format!( - "Stored event ID doesn't match sent event. Expected: {}, Got {} events", - event_id, events.len() - ))?; - - // Verify key tags are present - let has_clone_tag = stored_event.tags.iter() - .any(|t| { - t.kind() == TagKind::Custom("clone".into()) && - t.content().map(|c| c.contains(&http_url)).unwrap_or(false) - }); - - let has_relays_tag = stored_event.tags.iter() - .any(|t| { - t.kind() == TagKind::Custom("relays".into()) && - t.content() == Some(&relay_url) - }); - - if !has_clone_tag { - return Err(format!("Stored event missing clone tag with service URL ({})", http_url)); - } - - if !has_relays_tag { - return Err(format!("Stored event missing relays tag with service URL ({})", relay_url)); - } - - Ok(()) - }) - .await - } - - /// Test: Reject repo announcements not listing service in clone tag - /// - /// Spec: Line 5 of ../grasp/01.md - /// Requirement: MUST reject announcements not listing service (unless GRASP-05) - async fn test_reject_repo_announcement_missing_clone_tag(client: &AuditClient) -> TestResult { - TestResult::new( - "reject_repo_announcement_missing_clone_tag", - "GRASP-01:nostr-relay:5", - "Reject repository announcements without service in clone tag", - ) - .run(|| async { - // Get relay URL from client - let relay_url = client.client().relays().await - .keys() - .next() - .ok_or("No relay connected - client has no active relay connections")? - .to_string(); - - // Create unique repository identifier - let timestamp = Timestamp::now().as_u64(); - let repo_id = format!("test-repo-no-clone-{}", timestamp); - - // Create repo announcement WITHOUT service in clone tag - let event = client.event_builder(Kind::GitRepoAnnouncement, "") - .tag(Tag::identifier(&repo_id)) - .tag(Tag::custom(TagKind::Custom("name".into()), vec!["Test Repo No Clone"])) - .tag(Tag::custom(TagKind::Custom("clone".into()), vec!["https://github.com/user/repo.git"])) // NOT this service - .tag(Tag::custom(TagKind::Custom("relays".into()), vec![relay_url.clone()])) // Correct relay - .build(client.keys()) - .map_err(|e| format!("Failed to build event: {}", e))?; - - let event_id = event.id; - - // Send event - expect rejection - let send_result = client.send_event(event.clone()).await; - - // Query to verify event is NOT stored - let filter = Filter::new() - .kind(Kind::GitRepoAnnouncement) - .author(client.public_key()) - .identifier(&repo_id); - - let events = client.query(filter).await - .map_err(|e| format!("Failed to query events from relay: {}", e))?; - - // Verify event was rejected (not stored) - if events.iter().any(|e| e.id == event_id) { - return Err(format!( - "Relay incorrectly accepted announcement without service in clone tag. \ - Event ID: {}, Clone URL: https://github.com/user/repo.git (should require {})", - event_id, relay_url - )); - } - - Ok(()) - }) - .await - } - - /// Test: Reject repo announcements not listing service in relays tag - /// - /// Spec: Line 5 of ../grasp/01.md - /// Requirement: MUST reject announcements not listing service in relays - async fn test_reject_repo_announcement_missing_relays_tag(client: &AuditClient) -> TestResult { - TestResult::new( - "reject_repo_announcement_missing_relays_tag", - "GRASP-01:nostr-relay:5", - "Reject repository announcements without service in relays tag", - ) - .run(|| async { - // Get relay URL from client - let relay_url = client.client().relays().await - .keys() - .next() - .ok_or("No relay connected - client has no active relay connections")? - .to_string(); - - // Convert WebSocket URL to HTTP URL for clone tag - let http_url = relay_url - .replace("ws://", "http://") - .replace("wss://", "https://"); - - // Create unique repository identifier - let timestamp = Timestamp::now().as_u64(); - let repo_id = format!("test-repo-no-relays-{}", timestamp); - - // Create repo announcement WITHOUT service in relays tag - let event = client.event_builder(Kind::GitRepoAnnouncement, "") - .tag(Tag::identifier(&repo_id)) - .tag(Tag::custom(TagKind::custom("name"), vec!["Test Repo No Relays"])) - .tag(Tag::custom(TagKind::custom("clone"), vec![format!("{}/{}/test-repo.git", http_url, client.public_key())])) // Correct clone - .tag(Tag::custom(TagKind::custom("relays"), vec!["wss://relay.damus.io"])) // NOT this service - .build(client.keys()) - .map_err(|e| format!("Failed to build event: {}", e))?; - - let event_id = event.id; - - // Send event - expect rejection - let _send_result = client.send_event(event.clone()).await; - - // Query to verify event is NOT stored - let filter = Filter::new() - .kind(Kind::GitRepoAnnouncement) - .author(client.public_key()) - .identifier(&repo_id); - - let events = client.query(filter).await - .map_err(|e| format!("Failed to query events from relay: {}", e))?; - - // Verify event was rejected (not stored) - if events.iter().any(|e| e.id == event_id) { - return Err(format!( - "Relay incorrectly accepted announcement without service in relays tag. \ - Event ID: {}, Relays URL: wss://relay.damus.io (should require {})", - event_id, relay_url - )); - } - - Ok(()) - }) - .await - } - - // ========================================================================= - // Repository State Announcement Tests - // ========================================================================= - - /// Test: Accept valid repository state announcements - /// - /// Spec: Lines 6-7 of ../grasp/01.md - /// Requirement: MUST accept repo state announcements with d, maintainers, and r tags - async fn test_accept_valid_repo_state_announcement(client: &AuditClient) -> TestResult { - TestResult::new( - "accept_valid_repo_state_announcement", - "GRASP-01:nostr-relay:6-7", - "Accept valid state announcements after repo announcement accepted", - ) - .run(|| async { - // First, create a repository announcement (kind 30617) by the same author - let test_name = format!("test-repo-multi-refs-{}", Timestamp::now().as_u64()); - let repo_event = client.create_repo_announcement(&test_name).await - .map_err(|e| format!("Failed to create repository announcement: {}", e))?; - - // Extract repo_id from the repository announcement - let repo_id = repo_event.tags.iter() - .find(|t| t.kind() == TagKind::d()) - .and_then(|t| t.content()) - .ok_or("Missing d tag in repository announcement")? - .to_string(); - - // Get maintainer npub - let npub = client.public_key().to_bech32() - .map_err(|e| format!("Failed to convert public key to bech32: {}", e))?; - - // Create kind 30618 repository state announcement with multiple refs - // Format: ["r", "refs/heads/main", ""] - let event = client.event_builder(Kind::Custom(30618), "") - .tag(Tag::identifier(&repo_id)) - .tag(Tag::custom(TagKind::custom("refs/heads/main"), vec![ - "abc123def456789012345678901234567890abcd" - ])) - .tag(Tag::custom(TagKind::custom("refs/heads/develop"), vec![ - "def456789012345678901234567890abcdef123" - ])) - .tag(Tag::custom(TagKind::custom("refs/tags/v1.0.0"), vec![ - "123456789012345678901234567890abcdef456" - ])) - .tag(Tag::custom(TagKind::custom("HEAD"), vec![ - "ref: refs/heads/main" - ])) - .build(client.keys()) - .map_err(|e| format!("Failed to build state announcement: {}", e))?; - - let event_id = event.id; - - // Send the repo announcement event - client.send_event(repo_event.clone()).await - .map_err(|e| format!("Failed to send state announcement to relay: {}", e))?; - - // Send the state event - client.send_event(event.clone()).await - .map_err(|e| format!("Failed to send state announcement to relay: {}", e))?; - - // Query back to verify it was accepted and stored - let filter = Filter::new() - .kind(Kind::Custom(30618)) - .author(client.public_key()) - .identifier(&repo_id); - - let events = client.query(filter).await - .map_err(|e| format!("Failed to query events from relay: {}", e))?; - - // Verify we got the event back - if events.is_empty() { - return Err(format!( - "Event was not stored in relay (possibly rejected). Event ID: {}, Repo ID: {}", - event_id, repo_id - )); - } - - Ok(()) - }) - .await - } - - - // ========================================================================= - // Related Event Acceptance Tests - // ========================================================================= - - /// Test: Accept events tagging accepted repo announcements - /// - /// Spec: Lines 7-9 of ../grasp/01.md - /// Requirement: MUST accept events that tag accepted repo announcements - async fn test_accept_event_tagging_repo_announcement(client: &AuditClient) -> TestResult { - TestResult::new( - "accept_event_tagging_repo_announcement", - "GRASP-01:nostr-relay:7-9", - "Accept events that tag accepted repository announcements", - ) - .run(|| async { - // TODO: Implementation - // 1. Create and send kind 30617 repo announcement - // 2. Create kind 1621 (issue) event with: - // - a tag: "30617:{pubkey}:{d-tag}" - // - p tag: repo owner pubkey - // - subject tag: "Test Issue" - // - content: "This is a test issue" - // 3. Send issue event - // 4. Verify acceptance - // 5. Query to confirm issue is stored - - Err("Not implemented yet".to_string()) - }) - .await - } - - /// Test: Accept events tagged by repo announcements - /// - /// Spec: Lines 7-9 of ../grasp/01.md - /// Requirement: MUST accept events tagged by accepted announcements - async fn test_accept_event_tagged_by_repo(client: &AuditClient) -> TestResult { - TestResult::new( - "accept_event_tagged_by_repo", - "GRASP-01:nostr-relay:7-9", - "Accept events that are tagged by accepted repository announcements", - ) - .run(|| async { - // TODO: Implementation - // 1. Create kind 1 note event (regular note) - // 2. Send the note - // 3. Create kind 30617 repo announcement that tags the note - // - Include e tag pointing to note event ID - // 4. Send repo announcement - // 5. Verify both events are stored - // 6. This tests that related events are retained - - Err("Not implemented yet".to_string()) - }) - .await - } - - /// Test: Accept patches (kind 1617) for accepted repos - /// - /// Spec: Lines 8-9 of ../grasp/01.md - /// Requirement: MUST accept patches for accepted repos - async fn test_accept_patch_for_repo(client: &AuditClient) -> TestResult { - TestResult::new( - "accept_patch_for_repo", - "GRASP-01:nostr-relay:8-9", - "Accept patch events (kind 1617) for accepted repositories", - ) - .run(|| async { - // TODO: Implementation - // 1. Create and send kind 30617 repo announcement - // 2. Create kind 1617 patch event with: - // - a tag: "30617:{pubkey}:{d-tag}" - // - p tag: repo owner - // - r tag: earliest-unique-commit-id - // - t tag: "root" (first patch in series) - // - content: actual git format-patch output - // 3. Send patch event - // 4. Verify acceptance - // 5. Query to confirm patch is stored - - Err("Not implemented yet".to_string()) - }) - .await - } - - /// Test: Accept pull requests (kind 1618) for accepted repos - /// - /// Spec: Lines 8-9 of ../grasp/01.md - /// Requirement: MUST accept PRs for accepted repos - async fn test_accept_pull_request_for_repo(client: &AuditClient) -> TestResult { - TestResult::new( - "accept_pull_request_for_repo", - "GRASP-01:nostr-relay:8-9", - "Accept pull request events (kind 1618) for accepted repositories", - ) - .run(|| async { - // TODO: Implementation - // 1. Create and send kind 30617 repo announcement - // 2. Create kind 1618 PR event with: - // - a tag: "30617:{pubkey}:{d-tag}" - // - p tag: repo owner - // - r tag: earliest-unique-commit-id - // - subject tag: "Add feature X" - // - c tag: commit SHA of PR tip - // - clone tag: URL where commit can be fetched - // - content: PR description - // 3. Send PR event - // 4. Verify acceptance - // 5. Query to confirm PR is stored - - Err("Not implemented yet".to_string()) - }) - .await - } - - /// Test: Accept issues (kind 1621) for accepted repos - /// - /// Spec: Lines 8-9 of ../grasp/01.md - /// Requirement: MUST accept issues for accepted repos - async fn test_accept_issue_for_repo(client: &AuditClient) -> TestResult { - TestResult::new( - "accept_issue_for_repo", - "GRASP-01:nostr-relay:8-9", - "Accept issue events (kind 1621) for accepted repositories", - ) - .run(|| async { - // TODO: Implementation - // 1. Create and send kind 30617 repo announcement - // 2. Create kind 1621 issue event with: - // - a tag: "30617:{pubkey}:{d-tag}" - // - p tag: repo owner - // - subject tag: "Bug: Something is broken" - // - t tag: "bug" (label) - // - content: issue description - // 3. Send issue event - // 4. Verify acceptance - // 5. Query to confirm issue is stored - - Err("Not implemented yet".to_string()) - }) - .await - } - - /// Test: Accept replies to accepted patches/PRs/issues - /// - /// Spec: Lines 8-9 of ../grasp/01.md - /// Requirement: MUST accept replies to accepted events - async fn test_accept_reply_to_issue(client: &AuditClient) -> TestResult { - TestResult::new( - "accept_reply_to_issue", - "GRASP-01:nostr-relay:8-9", - "Accept reply events to accepted issues/patches/PRs", - ) - .run(|| async { - // TODO: Implementation - // 1. Create and send kind 30617 repo announcement - // 2. Create and send kind 1621 issue - // 3. Create NIP-22 comment (kind 1111) replying to issue: - // - E tag: issue event ID - // - P tag: issue author - // - content: reply text - // 4. Send reply event - // 5. Verify acceptance - // 6. Query to confirm reply is stored - - Err("Not implemented yet".to_string()) - }) - .await - } - - // ========================================================================= - // NIP-11 Relay Information Tests - // ========================================================================= - - /// Test: Serve NIP-11 document - /// - /// Spec: Line 11 of ../grasp/01.md - /// Requirement: MUST serve NIP-11 document - async fn test_nip11_document_exists(client: &AuditClient) -> TestResult { - TestResult::new( - "nip11_document_exists", - "GRASP-01:nostr-relay:11", - "Serve NIP-11 relay information document", - ) - .run(|| async { - // TODO: Implementation - // 1. Extract HTTP(S) URL from client's WebSocket URL - // - ws://localhost:8081 -> http://localhost:8081 - // - wss://relay.example.com -> https://relay.example.com - // 2. HTTP GET to base URL with header: - // - Accept: application/nostr+json - // 3. Verify 200 OK response - // 4. Verify response is valid JSON - // 5. Parse as NIP-11 document - // 6. Verify has required fields (name, description, etc.) - - Err("Not implemented yet".to_string()) - }) - .await - } - - /// Test: NIP-11 includes supported_grasps field - /// - /// Spec: Line 12 of ../grasp/01.md - /// Requirement: MUST list supported GRASPs as string array - async fn test_nip11_supported_grasps_field(client: &AuditClient) -> TestResult { - TestResult::new( - "nip11_supported_grasps_field", - "GRASP-01:nostr-relay:12", - "NIP-11 document includes supported_grasps field with GRASP-01", - ) - .run(|| async { - // TODO: Implementation - // 1. Fetch NIP-11 document (same as above) - // 2. Verify `supported_grasps` field exists - // 3. Verify it's a JSON array of strings - // 4. Verify array includes "GRASP-01" - // 5. Verify format: each entry matches pattern "GRASP-\d{2}" - // 6. Document other GRASPs found (for info) - - Err("Not implemented yet".to_string()) - }) - .await - } - - /// Test: NIP-11 includes repo_acceptance_criteria field - /// - /// Spec: Line 13 of ../grasp/01.md - /// Requirement: MUST list repository acceptance criteria - async fn test_nip11_repo_acceptance_criteria_field(client: &AuditClient) -> TestResult { - TestResult::new( - "nip11_repo_acceptance_criteria_field", - "GRASP-01:nostr-relay:13", - "NIP-11 document includes repo_acceptance_criteria field", - ) - .run(|| async { - // TODO: Implementation - // 1. Fetch NIP-11 document - // 2. Verify `repo_acceptance_criteria` field exists - // 3. Verify it's a string (human-readable) - // 4. Verify non-empty - // 5. Document the criteria (for info) - // Examples: "Must list this relay in clone and relays tags" - // "Pre-payment required via Lightning invoice" - - Err("Not implemented yet".to_string()) - }) - .await - } - - /// Test: NIP-11 curation field handling - /// - /// Spec: Line 14 of ../grasp/01.md - /// Requirement: MUST include curation if curated, omit otherwise - async fn test_nip11_curation_field(client: &AuditClient) -> TestResult { - TestResult::new( - "nip11_curation_field", - "GRASP-01:nostr-relay:14", - "NIP-11 curation field present if curated, absent otherwise", - ) - .run(|| async { - // TODO: Implementation - // 1. Fetch NIP-11 document - // 2. Check if `curation` field exists - // 3. If present: - // - Verify it's a non-empty string - // - Document the curation policy - // 4. If absent: - // - Document that no curation beyond SPAM prevention - // 5. Both cases are valid per spec - - Err("Not implemented yet".to_string()) - }) - .await - } - - // ========================================================================= - // Policy Tests (Document Allowed Behavior) - // ========================================================================= - - /// Test: Custom rejection criteria allowed - /// - /// Spec: Line 6 of ../grasp/01.md - /// Requirement: MAY reject based on custom criteria (document behavior) - async fn test_custom_rejection_allowed(client: &AuditClient) -> TestResult { - TestResult::new( - "custom_rejection_allowed", - "GRASP-01:nostr-relay:6", - "Document that custom rejection criteria are allowed", - ) - .run(|| async { - // TODO: Implementation - // This is a policy test, not a functional test - // - // The spec says relay MAY reject based on: - // - Pre-payment - // - Quotas - // - WoT (Web of Trust) - // - Whitelist - // - SPAM prevention - // - etc. - // - // This test should: - // 1. Document that such rejections are allowed - // 2. Check NIP-11 repo_acceptance_criteria for policy - // 3. Optionally test if relay enforces any criteria - // 4. Mark as PASS (this is permissive, not mandatory) - - Ok(()) // This is always allowed - }) - .await - } - - /// Test: SPAM prevention allowed - /// - /// Spec: Line 10 of ../grasp/01.md - /// Requirement: MAY reject/delete for SPAM prevention - async fn test_spam_prevention_allowed(client: &AuditClient) -> TestResult { - TestResult::new( - "spam_prevention_allowed", - "GRASP-01:nostr-relay:10", - "Document that SPAM prevention is allowed", - ) - .run(|| async { - // TODO: Implementation - // Similar to above - this is permissive - // - // The spec says relay MAY reject or delete events for: - // - Generic SPAM prevention - // - Curation (WoT, whitelist, user bans, banned topics) - // - // This test should: - // 1. Document that SPAM prevention is allowed - // 2. Check NIP-11 curation field for policy - // 3. Mark as PASS (this is implementation-specific) - - Ok(()) // This is always allowed - }) - .await - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::AuditConfig; - -#[tokio::test] -#[ignore] // Requires running relay -async fn test_grasp01_nostr_relay_against_relay() { - // Read relay URL from environment variable - must be supplied - let relay_url = std::env::var("RELAY_URL") - .expect("RELAY_URL environment variable must be set. Example: RELAY_URL=ws://localhost:18081"); - - let config = AuditConfig::ci(); - let client = AuditClient::new(&relay_url, config) - .await - .expect(&format!( - "Failed to connect to relay at {}. Ensure relay is running and accessible. \ - Try: docker run --rm -p 18081:8081 ghcr.io/danconwaydev/ngit-relay:latest", - relay_url - )); - - let results = Grasp01NostrRelayTests::run_all(&client).await; - results.print_report(); - - // Don't assert all passed yet - tests not implemented - // assert!(results.all_passed(), "Some GRASP-01 Nostr relay tests failed"); - } -} diff --git a/grasp-audit/src/specs/mod.rs b/grasp-audit/src/specs/mod.rs index 834bf9e..c1c277c 100644 --- a/grasp-audit/src/specs/mod.rs +++ b/grasp-audit/src/specs/mod.rs @@ -1,7 +1,10 @@ //! Test specifications -pub mod grasp01_nostr_relay; -pub mod nip01_smoke; +pub mod grasp01; -pub use grasp01_nostr_relay::Grasp01NostrRelayTests; -pub use nip01_smoke::Nip01SmokeTests; +// Re-export all test structs from grasp01 module +pub use grasp01::{ + EventAcceptancePolicyTests, + Nip01SmokeTests, + Nip11DocumentTests, +}; diff --git a/grasp-audit/src/specs/nip01_smoke.rs b/grasp-audit/src/specs/nip01_smoke.rs deleted file mode 100644 index 9ed0f56..0000000 --- a/grasp-audit/src/specs/nip01_smoke.rs +++ /dev/null @@ -1,311 +0,0 @@ -//! NIP-01 Smoke Tests -//! -//! These tests verify basic Nostr relay functionality. -//! We don't comprehensively test NIP-01 because rust-nostr already has 1000+ tests. -//! These are just smoke tests to ensure the relay is working at all. - -use crate::{AuditClient, AuditResult, TestResult}; -use nostr_sdk::prelude::*; - -pub struct Nip01SmokeTests; - -impl Nip01SmokeTests { - /// Run all NIP-01 smoke tests - pub async fn run_all(client: &AuditClient) -> AuditResult { - let mut results = AuditResult::new("NIP-01 Smoke Tests"); - - // Run tests sequentially to avoid future type issues - results.add(Self::test_websocket_connection(client).await); - results.add(Self::test_send_receive_event(client).await); - results.add(Self::test_create_subscription(client).await); - results.add(Self::test_close_subscription(client).await); - results.add(Self::test_reject_invalid_signature(client).await); - results.add(Self::test_reject_invalid_event_id(client).await); - - results - } - - /// Test 1: Can establish WebSocket connection - /// - /// Spec: NIP-01 basic requirement - /// Requirement: MUST serve a relay at / via WebSocket - async fn test_websocket_connection(client: &AuditClient) -> TestResult { - TestResult::new( - "websocket_connection", - "NIP-01:basic", - "Can establish WebSocket connection to /", - ) - .run(|| async { - if !client.is_connected().await { - return Err("Failed to connect to relay".to_string()); - } - - Ok(()) - }) - .await - } - - /// Test 2: Can send EVENT and receive OK response - /// - /// Spec: NIP-01 EVENT message - /// Requirement: Relay MUST accept valid EVENT messages - /// - /// For GRASP servers, we send a NIP-34 repository announcement that lists - /// the GRASP server in clone and relays tags (required for acceptance). - async fn test_send_receive_event(client: &AuditClient) -> TestResult { - TestResult::new( - "send_receive_event", - "NIP-01:event-message", - "Can send EVENT and receive OK response", - ) - .run(|| async { - // Create a NIP-34 announcement event - let event = client.create_repo_announcement("send_receive_event").await - .map_err(|e| format!("Failed to create announcement: {}", e))?; - - // Send event - let event_id = client - .send_event(event.clone()) - .await - .map_err(|e| format!("Failed to send event: {}", e))?; - - // Verify we got an event ID back - if event_id != event.id { - return Err(format!( - "Event ID mismatch: sent {}, got {}", - event.id, event_id - )); - } - - // Wait a bit for event to be indexed - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - - // Try to query it back - let filter = Filter::new() - .kind(Kind::Custom(30617)) - .id(event_id); - - let events = client - .query(filter) - .await - .map_err(|e| format!("Failed to query event: {}", e))?; - - if events.is_empty() { - // Debug: try querying without audit client filtering - eprintln!("Event not found with audit client query, trying direct client query..."); - let direct_filter = Filter::new().kind(Kind::Custom(30617)).id(event_id); - let direct_events = client.client().fetch_events(direct_filter, std::time::Duration::from_secs(5)).await - .map_err(|e| format!("Direct query failed: {}", e))?; - let direct_vec: Vec = direct_events.into_iter().collect(); - eprintln!("Direct query found {} events", direct_vec.len()); - if !direct_vec.is_empty() { - eprintln!("Event tags: {:?}", direct_vec[0].tags); - } - return Err(format!("Event not found after sending (direct query found {})", direct_vec.len())); - } - - if events[0].id != event_id { - return Err("Retrieved event has different ID".to_string()); - } - - Ok(()) - }) - .await - } - - /// Test 3: Can create subscription with REQ - /// - /// Spec: NIP-01 REQ message - /// Requirement: Relay MUST support REQ subscriptions - async fn test_create_subscription(client: &AuditClient) -> TestResult { - TestResult::new( - "create_subscription", - "NIP-01:req-message", - "Can create subscription with REQ and receive EOSE", - ) - .run(|| async { - // Create a NIP-34 announcement event (accepted by GRASP relays) - let event = client.create_repo_announcement("create_subscription").await - .map_err(|e| format!("Failed to create announcement: {}", e))?; - - let event_id = client - .send_event(event.clone()) - .await - .map_err(|e| format!("Failed to send event: {}", e))?; - - // Subscribe to NIP-34 announcements from this author - let filter = Filter::new() - .kind(Kind::Custom(30617)) - .author(client.public_key()); - - let events = client - .subscribe(vec![filter], Some(std::time::Duration::from_secs(5))) - .await - .map_err(|e| format!("Failed to subscribe: {}", e))?; - - // Should have at least our event - if events.is_empty() { - return Err("No events received from subscription".to_string()); - } - - Ok(()) - }) - .await - } - - /// Test 4: Can close subscription with CLOSE - /// - /// Spec: NIP-01 CLOSE message - /// Requirement: Relay MUST support CLOSE to end subscriptions - async fn test_close_subscription(client: &AuditClient) -> TestResult { - TestResult::new( - "close_subscription", - "NIP-01:close-message", - "Can close subscriptions", - ) - .run(|| async { - // For now, we just verify we can query events - // Full subscription management with CLOSE would require - // lower-level WebSocket access - - let filter = Filter::new() - .kind(Kind::TextNote) - .limit(1); - - let _events = client - .subscribe(vec![filter], Some(std::time::Duration::from_secs(2))) - .await - .map_err(|e| format!("Failed to subscribe: {}", e))?; - - // If we got here, subscription worked - Ok(()) - }) - .await - } - - /// Test 5: Rejects events with invalid signatures - /// - /// Spec: NIP-01 event validation - /// Requirement: Relay MUST reject events with invalid signatures - async fn test_reject_invalid_signature(client: &AuditClient) -> TestResult { - TestResult::new( - "reject_invalid_signature", - "NIP-01:validation", - "Rejects events with invalid signatures", - ) - .run(|| async { - // Create a valid event - let event = client - .event_builder(Kind::TextNote, "Invalid signature test") - .build(client.keys()) - .map_err(|e| format!("Failed to build event: {}", e))?; - - // Corrupt the signature by creating a new event with wrong sig - // We'll use a different key to sign, creating an invalid signature - let wrong_keys = Keys::generate(); - let wrong_event = EventBuilder::new(event.kind, event.content.clone()) - .tags(event.tags.clone()) - .sign_with_keys(&wrong_keys) - .map_err(|e| format!("Failed to build wrong event: {}", e))?; - - // Create event JSON with mismatched pubkey and signature - // This should be rejected by the relay - let invalid_event_json = serde_json::json!({ - "id": event.id.to_hex(), - "pubkey": event.pubkey.to_hex(), - "created_at": event.created_at.as_u64(), - "kind": event.kind.as_u16(), - "tags": event.tags, - "content": event.content, - "sig": wrong_event.sig.to_string(), // Wrong signature! - }); - - // Parse it back to an Event - let invalid_event: Event = serde_json::from_value(invalid_event_json) - .map_err(|e| format!("Failed to create invalid event: {}", e))?; - - // Try to send the invalid event - let result = client.send_event(invalid_event).await; - - // We expect this to fail - if result.is_ok() { - return Err("Relay accepted event with invalid signature".to_string()); - } - - Ok(()) - }) - .await - } - - /// Test 6: Rejects events with invalid event IDs - /// - /// Spec: NIP-01 event ID validation - /// Requirement: Relay MUST reject events where ID doesn't match hash - async fn test_reject_invalid_event_id(client: &AuditClient) -> TestResult { - TestResult::new( - "reject_invalid_event_id", - "NIP-01:validation", - "Rejects events with invalid event IDs", - ) - .run(|| async { - // Create a valid event - let event = client - .event_builder(Kind::TextNote, "Invalid ID test") - .build(client.keys()) - .map_err(|e| format!("Failed to build event: {}", e))?; - - // Create event JSON with corrupted ID - let invalid_event_json = serde_json::json!({ - "id": EventId::all_zeros().to_hex(), // Wrong ID! - "pubkey": event.pubkey.to_hex(), - "created_at": event.created_at.as_u64(), - "kind": event.kind.as_u16(), - "tags": event.tags, - "content": event.content, - "sig": event.sig.to_string(), - }); - - // Parse it back to an Event - let invalid_event: Event = serde_json::from_value(invalid_event_json) - .map_err(|e| format!("Failed to create invalid event: {}", e))?; - - // Try to send the invalid event - let result = client.send_event(invalid_event).await; - - // We expect this to fail - if result.is_ok() { - return Err("Relay accepted event with invalid ID".to_string()); - } - - Ok(()) - }) - .await - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::AuditConfig; - - // Note: These tests require a running relay - // They are integration tests, not unit tests - - #[tokio::test] - #[ignore] // Ignore by default since it needs a running relay - async fn test_smoke_tests_against_relay() { - // RELAY_URL env var must be set - no default fallback - let relay_url = std::env::var("RELAY_URL") - .expect("RELAY_URL environment variable must be set for integration tests"); - - let config = AuditConfig::ci(); - let client = AuditClient::new(&relay_url, config) - .await - .expect("Failed to connect to relay"); - - let results = Nip01SmokeTests::run_all(&client).await; - results.print_report(); - - assert!(results.all_passed(), "Some smoke tests failed"); - } -} -- cgit v1.2.3