From c2c0cdba4af434043f3fa707231d8f5a7e3fd882 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Tue, 4 Nov 2025 14:33:18 +0000 Subject: add announcement tests --- README.md | 70 ++++- src/nostr/events.rs | 606 ++++++++++++++++++++++++++++++++++++++++++++ src/nostr/mod.rs | 1 + src/nostr/relay.rs | 32 ++- src/storage/mod.rs | 6 + tests/announcement_tests.rs | 411 ++++++++++++++++++++++++++++++ 6 files changed, 1113 insertions(+), 13 deletions(-) create mode 100644 src/nostr/events.rs create mode 100644 tests/announcement_tests.rs diff --git a/README.md b/README.md index 920d1d5..0d454bc 100644 --- a/README.md +++ b/README.md @@ -76,17 +76,22 @@ This approach provides: git clone https://gitworkshop.dev/ngit-grasp cd ngit-grasp -# Build -cargo build --release +# Build (using Nix for reproducible environment) +nix develop -c cargo build --release # Configure cp .env.example .env # Edit .env with your settings # Run -cargo run --release +nix develop -c cargo run --release + +# Run tests +nix develop -c cargo test --lib ``` +**Don't have Nix?** See [Getting Started Tutorial](docs/tutorials/getting-started.md) for alternative setup methods. + ## Configuration Environment variables (see `.env.example`): @@ -114,24 +119,65 @@ We use the **[Diátaxis](https://diataxis.fr/)** framework for documentation: See [Architecture Overview](docs/explanation/architecture.md) for system design and [Test Strategy](docs/reference/test-strategy.md) for testing approach. +### Running Tests + +We have two test suites: + +**1. Main Project Tests (ngit-grasp)** + ```bash -# Run tests -cargo test +# Run unit tests (no external dependencies) +nix develop -c cargo test --lib + +# Run integration tests (tests our relay implementation) +# First, start ngit-grasp relay in one terminal: +NGIT_BIND_ADDRESS=127.0.0.1:7000 nix develop -c cargo run + +# Then in another terminal, run integration tests: +nix develop -c cargo test --test announcement_tests --ignored + +# Or use the test script (starts relay automatically): +./test_relay.sh +``` -# Run compliance tests -cargo test --test compliance +**2. GRASP Audit Tool (grasp-audit)** +The audit tool tests GRASP compliance of any relay (including ours or external ones). + +```bash +# Enter grasp-audit directory +cd grasp-audit + +# Run unit tests +nix develop -c cargo test + +# Test against our ngit-grasp relay: +# First, start ngit-grasp (in another terminal): +cd .. && NGIT_BIND_ADDRESS=127.0.0.1:7000 nix develop -c cargo run + +# Then run audit: +nix develop -c cargo run -- --url ws://127.0.0.1:7000 + +# Or test against any external relay: +nix develop -c cargo run -- --url wss://relay.example.com +``` + +### Development Commands + +```bash # Run with logging -RUST_LOG=debug cargo run +RUST_LOG=debug nix develop -c cargo run # Check code -cargo clippy -cargo fmt --check +nix develop -c cargo clippy +nix develop -c cargo fmt --check -# Generate test coverage -cargo tarpaulin --out Html +# Generate test coverage (requires tarpaulin) +nix develop -c cargo tarpaulin --out Html ``` +**Note:** Always use `nix develop` to ensure the correct build environment. See [docs/how-to/nix-flakes.md](docs/how-to/nix-flakes.md) for details. + ## Project Structure ``` diff --git a/src/nostr/events.rs b/src/nostr/events.rs new file mode 100644 index 0000000..88aefed --- /dev/null +++ b/src/nostr/events.rs @@ -0,0 +1,606 @@ +/// NIP-34 Git Repository Event Handling +/// +/// This module handles Git repository announcements (kind 30617) and +/// repository state announcements (kind 30618) according to NIP-34 and GRASP-01. +/// +/// Reference: +/// - NIP-34: https://nips.nostr.com/34 +/// - GRASP-01: https://gitworkshop.dev/danconwaydev.com/grasp/01.md + +use anyhow::{anyhow, Result}; +use nostr_sdk::{Event, Kind, TagKind, ToBech32}; + +/// NIP-34 Repository Announcement (kind 30617) +pub const KIND_REPOSITORY_ANNOUNCEMENT: u16 = 30617; + +/// NIP-34 Repository State Announcement (kind 30618) +pub const KIND_REPOSITORY_STATE: u16 = 30618; + +/// Repository announcement details extracted from NIP-34 event +#[derive(Debug, Clone)] +pub struct RepositoryAnnouncement { + pub event: Event, + pub identifier: String, + pub name: Option, + pub description: Option, + pub clone_urls: Vec, + pub relays: Vec, + pub web_urls: Vec, + pub maintainers: Vec, +} + +impl RepositoryAnnouncement { + /// Parse a repository announcement from a NIP-34 kind 30617 event + pub fn from_event(event: Event) -> Result { + if event.kind != Kind::from(KIND_REPOSITORY_ANNOUNCEMENT) { + return Err(anyhow!( + "Invalid event kind: expected {}, got {}", + KIND_REPOSITORY_ANNOUNCEMENT, + event.kind + )); + } + + // Extract identifier (required) + let identifier = event + .tags + .iter() + .find(|t| t.kind() == TagKind::d()) + .and_then(|t| t.content()) + .ok_or_else(|| anyhow!("Repository announcement missing 'd' tag (identifier)"))? + .to_string(); + + // Extract optional name + let name = event + .tags + .iter() + .find(|t| matches!(t.kind(), TagKind::Name)) + .and_then(|t| t.content()) + .map(|s| s.to_string()); + + // Extract description from content + let description = if event.content.is_empty() { + None + } else { + Some(event.content.clone()) + }; + + // Extract clone URLs + let clone_urls = event + .tags + .iter() + .filter(|t| matches!(t.kind(), TagKind::Clone)) + .flat_map(|t| { + let vec = t.clone().to_vec(); + // Skip first element (tag name), rest are values + vec.into_iter().skip(1) + }) + .collect(); + + // Extract relays + let relays = event + .tags + .iter() + .filter(|t| matches!(t.kind(), TagKind::Relays)) + .flat_map(|t| { + let vec = t.clone().to_vec(); + // Skip first element (tag name), rest are values + vec.into_iter().skip(1) + }) + .collect(); + + // Extract web URLs + let web_urls = event + .tags + .iter() + .filter(|t| { + if let TagKind::Custom(s) = t.kind() { + s.as_ref() == "web" + } else { + false + } + }) + .flat_map(|t| { + let vec = t.clone().to_vec(); + // Skip first element (tag name), rest are values + vec.into_iter().skip(1) + }) + .collect(); + + // Extract maintainers (other-user tags) + let maintainers = event + .tags + .iter() + .filter(|t| t.kind() == TagKind::p()) + .filter_map(|t| t.content()) + .map(|s| s.to_string()) + .collect(); + + Ok(RepositoryAnnouncement { + event, + identifier, + name, + description, + clone_urls, + relays, + web_urls, + maintainers, + }) + } + + /// Check if this announcement lists the given domain in clone URLs + pub fn has_clone_url(&self, domain: &str) -> bool { + self.clone_urls.iter().any(|url| url.contains(domain)) + } + + /// Check if this announcement lists the given relay + pub fn has_relay(&self, relay: &str) -> bool { + self.relays.iter().any(|r| r.contains(relay)) + } + + /// Check if this announcement lists the service (both clone and relay) + /// + /// GRASP-01 requirement: MUST reject announcements that do not list + /// the service in both `clone` and `relays` tags unless implementing GRASP-05. + pub fn lists_service(&self, domain: &str) -> bool { + self.has_clone_url(domain) && self.has_relay(domain) + } + + /// Get the npub of the repository owner + pub fn owner_npub(&self) -> String { + self.event.pubkey.to_bech32().unwrap_or_default() + } + + /// Get the repository path: /.git + pub fn repo_path(&self) -> String { + format!("{}/{}.git", self.owner_npub(), self.identifier) + } +} + +/// Repository state details extracted from NIP-34 event +#[derive(Debug, Clone)] +pub struct RepositoryState { + pub event: Event, + pub identifier: String, + pub branches: Vec, + pub tags: Vec, +} + +/// Branch state (ref with commit hash) +#[derive(Debug, Clone)] +pub struct BranchState { + pub name: String, + pub commit: String, +} + +/// Tag state (ref with commit hash) +#[derive(Debug, Clone)] +pub struct TagState { + pub name: String, + pub commit: String, +} + +impl RepositoryState { + /// Parse a repository state from a NIP-34 kind 30618 event + pub fn from_event(event: Event) -> Result { + if event.kind != Kind::from(KIND_REPOSITORY_STATE) { + return Err(anyhow!( + "Invalid event kind: expected {}, got {}", + KIND_REPOSITORY_STATE, + event.kind + )); + } + + // Extract identifier (required) + let identifier = event + .tags + .iter() + .find(|t| t.kind() == TagKind::d()) + .and_then(|t| t.content()) + .ok_or_else(|| anyhow!("Repository state missing 'd' tag (identifier)"))? + .to_string(); + + // Extract branches (refs/heads/*) + let branches = event + .tags + .iter() + .filter(|t| { + if let TagKind::Custom(s) = t.kind() { + s.as_ref() == "ref" + } else { + false + } + }) + .filter_map(|t| { + let parts = t.clone().to_vec(); + if parts.len() >= 3 && parts[1].starts_with("refs/heads/") { + Some(BranchState { + name: parts[1].strip_prefix("refs/heads/").unwrap().to_string(), + commit: parts[2].clone(), + }) + } else { + None + } + }) + .collect(); + + // Extract tags (refs/tags/*) + let tags = event + .tags + .iter() + .filter(|t| { + if let TagKind::Custom(s) = t.kind() { + s.as_ref() == "ref" + } else { + false + } + }) + .filter_map(|t| { + let parts = t.clone().to_vec(); + if parts.len() >= 3 && parts[1].starts_with("refs/tags/") { + Some(TagState { + name: parts[1].strip_prefix("refs/tags/").unwrap().to_string(), + commit: parts[2].clone(), + }) + } else { + None + } + }) + .collect(); + + Ok(RepositoryState { + event, + identifier, + branches, + tags, + }) + } + + /// Get the commit hash for a branch + pub fn get_branch_commit(&self, branch: &str) -> Option<&str> { + self.branches + .iter() + .find(|b| b.name == branch) + .map(|b| b.commit.as_str()) + } + + /// Get the commit hash for a tag + pub fn get_tag_commit(&self, tag: &str) -> Option<&str> { + self.tags + .iter() + .find(|t| t.name == tag) + .map(|t| t.commit.as_str()) + } + + /// Get the owner npub + pub fn owner_npub(&self) -> String { + self.event.pubkey.to_bech32().unwrap_or_default() + } +} + +/// Validate a repository announcement according to GRASP-01 +/// +/// Returns Ok(()) if valid, Err with reason if invalid. +pub fn validate_announcement(event: &Event, domain: &str) -> Result<()> { + // Must be kind 30617 + if event.kind != Kind::from(KIND_REPOSITORY_ANNOUNCEMENT) { + return Err(anyhow!("Invalid kind: expected {}", KIND_REPOSITORY_ANNOUNCEMENT)); + } + + // Must have identifier + let has_identifier = event + .tags + .iter() + .any(|t| t.kind() == TagKind::d()); + if !has_identifier { + return Err(anyhow!("Missing required 'd' tag (identifier)")); + } + + // Parse full announcement to validate structure + let announcement = RepositoryAnnouncement::from_event(event.clone())?; + + // GRASP-01: MUST reject announcements that do not list the service + // in both `clone` and `relays` tags unless implementing GRASP-05 + if !announcement.lists_service(domain) { + return Err(anyhow!( + "Announcement must list service in both 'clone' and 'relays' tags. \ + Found clone URLs: {:?}, relays: {:?}", + announcement.clone_urls, + announcement.relays + )); + } + + Ok(()) +} + +/// Validate a repository state announcement according to GRASP-01 +/// +/// Returns Ok(()) if valid, Err with reason if invalid. +pub fn validate_state(event: &Event) -> Result<()> { + // Must be kind 30618 + if event.kind != Kind::from(KIND_REPOSITORY_STATE) { + return Err(anyhow!("Invalid kind: expected {}", KIND_REPOSITORY_STATE)); + } + + // Must have identifier + let has_identifier = event + .tags + .iter() + .any(|t| t.kind() == TagKind::d()); + if !has_identifier { + return Err(anyhow!("Missing required 'd' tag (identifier)")); + } + + // Parse full state to validate structure + let _state = RepositoryState::from_event(event.clone())?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr_sdk::{EventBuilder, Keys, Tag}; + + fn create_test_keys() -> Keys { + Keys::generate() + } + + fn create_announcement_event( + keys: &Keys, + identifier: &str, + clone_urls: Vec<&str>, + relays: Vec<&str>, + ) -> Event { + use nostr_sdk::Tag; + + let mut tags = vec![Tag::custom( + nostr_sdk::TagKind::d(), + vec![identifier.to_string()], + )]; + + for url in clone_urls { + tags.push(Tag::custom( + nostr_sdk::TagKind::Clone, + vec![url.to_string()], + )); + } + + for relay in relays { + tags.push(Tag::custom( + nostr_sdk::TagKind::Relays, + vec![relay.to_string()], + )); + } + + EventBuilder::new( + Kind::from(KIND_REPOSITORY_ANNOUNCEMENT), + "Test repository", + ) + .tags(tags) + .sign_with_keys(keys) + .unwrap() + } + + fn create_state_event(keys: &Keys, identifier: &str, branches: Vec<(&str, &str)>) -> Event { + use nostr_sdk::Tag; + + let mut tags = vec![Tag::custom( + nostr_sdk::TagKind::d(), + vec![identifier.to_string()], + )]; + + for (branch, commit) in branches { + tags.push(Tag::custom( + nostr_sdk::TagKind::Custom("ref".into()), + vec![ + format!("refs/heads/{}", branch), + commit.to_string(), + ], + )); + } + + EventBuilder::new(Kind::from(KIND_REPOSITORY_STATE), "") + .tags(tags) + .sign_with_keys(keys) + .unwrap() + } + + #[test] + fn test_parse_announcement() { + let keys = create_test_keys(); + let event = create_announcement_event( + &keys, + "test-repo", + vec!["https://gitnostr.com/alice/test-repo.git"], + vec!["wss://gitnostr.com"], + ); + + let announcement = RepositoryAnnouncement::from_event(event).unwrap(); + + assert_eq!(announcement.identifier, "test-repo"); + assert_eq!(announcement.clone_urls.len(), 1); + assert_eq!(announcement.relays.len(), 1); + assert!(announcement.has_clone_url("gitnostr.com")); + assert!(announcement.has_relay("gitnostr.com")); + assert!(announcement.lists_service("gitnostr.com")); + } + + #[test] + fn test_parse_announcement_missing_identifier() { + let keys = create_test_keys(); + let event = EventBuilder::new( + Kind::from(KIND_REPOSITORY_ANNOUNCEMENT), + "Test repository", + ) + .sign_with_keys(&keys) + .unwrap(); + + let result = RepositoryAnnouncement::from_event(event); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("identifier")); + } + + #[test] + fn test_parse_state() { + let keys = create_test_keys(); + let event = create_state_event( + &keys, + "test-repo", + vec![("main", "a1b2c3d4"), ("develop", "e5f6g7h8")], + ); + + let state = RepositoryState::from_event(event).unwrap(); + + assert_eq!(state.identifier, "test-repo"); + assert_eq!(state.branches.len(), 2); + assert_eq!(state.get_branch_commit("main"), Some("a1b2c3d4")); + assert_eq!(state.get_branch_commit("develop"), Some("e5f6g7h8")); + } + + #[test] + fn test_validate_announcement_success() { + let keys = create_test_keys(); + let event = create_announcement_event( + &keys, + "test-repo", + vec!["https://gitnostr.com/alice/test-repo.git"], + vec!["wss://gitnostr.com"], + ); + + let result = validate_announcement(&event, "gitnostr.com"); + assert!(result.is_ok()); + } + + #[test] + fn test_validate_announcement_missing_clone() { + let keys = create_test_keys(); + let event = create_announcement_event( + &keys, + "test-repo", + vec![], // No clone URLs + vec!["wss://gitnostr.com"], + ); + + let result = validate_announcement(&event, "gitnostr.com"); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("clone")); + } + + #[test] + fn test_validate_announcement_missing_relay() { + let keys = create_test_keys(); + let event = create_announcement_event( + &keys, + "test-repo", + vec!["https://gitnostr.com/alice/test-repo.git"], + vec![], // No relays + ); + + let result = validate_announcement(&event, "gitnostr.com"); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("relays")); + } + + #[test] + fn test_validate_announcement_wrong_domain() { + let keys = create_test_keys(); + let event = create_announcement_event( + &keys, + "test-repo", + vec!["https://other-service.com/alice/test-repo.git"], + vec!["wss://other-service.com"], + ); + + let result = validate_announcement(&event, "gitnostr.com"); + assert!(result.is_err()); + } + + #[test] + fn test_validate_state_success() { + let keys = create_test_keys(); + let event = create_state_event(&keys, "test-repo", vec![("main", "a1b2c3d4")]); + + let result = validate_state(&event); + assert!(result.is_ok()); + } + + #[test] + fn test_validate_state_missing_identifier() { + let keys = create_test_keys(); + let event = EventBuilder::new(Kind::from(KIND_REPOSITORY_STATE), "") + .sign_with_keys(&keys) + .unwrap(); + + let result = validate_state(&event); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("identifier")); + } + + #[test] + fn test_announcement_maintainers() { + use nostr_sdk::Tag; + + let keys = create_test_keys(); + let maintainer_keys = create_test_keys(); + + let mut tags = vec![ + Tag::custom(nostr_sdk::TagKind::d(), vec!["test-repo".to_string()]), + Tag::custom( + nostr_sdk::TagKind::Clone, + vec!["https://gitnostr.com/alice/test-repo.git".to_string()], + ), + Tag::custom( + nostr_sdk::TagKind::Relays, + vec!["wss://gitnostr.com".to_string()], + ), + ]; + + // Add maintainer + tags.push(Tag::public_key(maintainer_keys.public_key())); + + let event = EventBuilder::new( + Kind::from(KIND_REPOSITORY_ANNOUNCEMENT), + "Test repository", + ) + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + + let announcement = RepositoryAnnouncement::from_event(event).unwrap(); + assert_eq!(announcement.maintainers.len(), 1); + } + + #[test] + fn test_state_with_tags() { + use nostr_sdk::Tag; + + let keys = create_test_keys(); + let mut tags = vec![Tag::custom( + nostr_sdk::TagKind::d(), + vec!["test-repo".to_string()], + )]; + + // Add branch + tags.push(Tag::custom( + nostr_sdk::TagKind::Custom("ref".into()), + vec!["refs/heads/main".to_string(), "a1b2c3d4".to_string()], + )); + + // Add tag + tags.push(Tag::custom( + nostr_sdk::TagKind::Custom("ref".into()), + vec!["refs/tags/v1.0.0".to_string(), "e5f6g7h8".to_string()], + )); + + let event = EventBuilder::new(Kind::from(KIND_REPOSITORY_STATE), "") + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + + let state = RepositoryState::from_event(event).unwrap(); + assert_eq!(state.branches.len(), 1); + assert_eq!(state.tags.len(), 1); + assert_eq!(state.get_branch_commit("main"), Some("a1b2c3d4")); + assert_eq!(state.get_tag_commit("v1.0.0"), Some("e5f6g7h8")); + } +} diff --git a/src/nostr/mod.rs b/src/nostr/mod.rs index 6193dd9..b485b91 100644 --- a/src/nostr/mod.rs +++ b/src/nostr/mod.rs @@ -1 +1,2 @@ +pub mod events; pub mod relay; diff --git a/src/nostr/relay.rs b/src/nostr/relay.rs index 5af9b04..1033b5b 100644 --- a/src/nostr/relay.rs +++ b/src/nostr/relay.rs @@ -1,6 +1,6 @@ use anyhow::Result; use futures_util::{SinkExt, StreamExt}; -use nostr_sdk::{Event, EventId, Filter}; +use nostr_sdk::{Event, EventId, Filter, Kind}; use serde_json::{json, Value}; use std::collections::HashMap; use std::net::SocketAddr; @@ -11,6 +11,7 @@ use tokio_tungstenite::{accept_async, tungstenite::Message}; use tracing::{debug, error, info, warn}; use crate::config::Config; +use crate::nostr::events::{validate_announcement, validate_state, KIND_REPOSITORY_ANNOUNCEMENT, KIND_REPOSITORY_STATE}; use crate::storage::Storage; type Subscriptions = Arc>>>; @@ -140,6 +141,35 @@ async fn handle_event(arr: &[Value], storage: &Storage) -> Result> { return Ok(vec![json!(["OK", event_id.to_hex(), true, "duplicate: event already exists"])]); } + // Validate repository announcements (kind 30617) + if event.kind == Kind::from(KIND_REPOSITORY_ANNOUNCEMENT) { + // Get domain from storage config + let domain = storage.get_domain(); + + match validate_announcement(&event, &domain) { + Ok(()) => { + info!("✅ Valid repository announcement: {} ({})", event_id, event.kind); + } + Err(e) => { + warn!("❌ Invalid repository announcement: {}", e); + return Ok(vec![json!(["OK", event_id.to_hex(), false, format!("invalid: {}", e)])]); + } + } + } + + // Validate repository state announcements (kind 30618) + if event.kind == Kind::from(KIND_REPOSITORY_STATE) { + match validate_state(&event) { + Ok(()) => { + info!("✅ Valid repository state: {} ({})", event_id, event.kind); + } + Err(e) => { + warn!("❌ Invalid repository state: {}", e); + return Ok(vec![json!(["OK", event_id.to_hex(), false, format!("invalid: {}", e)])]); + } + } + } + // Store the event storage.store_event(event.clone()).await?; diff --git a/src/storage/mod.rs b/src/storage/mod.rs index 2ec6d4e..eab8211 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -12,6 +12,7 @@ use crate::config::Config; pub struct Storage { events: Arc>>, data_path: String, + domain: String, } impl Storage { @@ -22,9 +23,14 @@ impl Storage { Ok(Storage { events: Arc::new(RwLock::new(HashMap::new())), data_path: config.relay_data_path.clone(), + domain: config.domain.clone(), }) } + pub fn get_domain(&self) -> String { + self.domain.clone() + } + pub async fn store_event(&self, event: Event) -> Result<()> { let mut events = self.events.write().await; events.insert(event.id.to_hex(), event); diff --git a/tests/announcement_tests.rs b/tests/announcement_tests.rs new file mode 100644 index 0000000..137ba5f --- /dev/null +++ b/tests/announcement_tests.rs @@ -0,0 +1,411 @@ +/// Integration tests for NIP-34 Repository Announcements (GRASP-01) +/// +/// Tests the acceptance and validation of repository announcements (kind 30617) +/// and repository state announcements (kind 30618) according to GRASP-01. +/// +/// Reference: GRASP-01, Lines 9-20 + +use futures_util::{SinkExt, StreamExt}; +use nostr_sdk::{EventBuilder, Keys, Kind, Tag, TagKind}; +use serde_json::{json, Value}; +use tokio::net::TcpStream; +use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream}; + +type WsStream = WebSocketStream>; + +const RELAY_URL: &str = "ws://127.0.0.1:7000"; +const DOMAIN: &str = "127.0.0.1:7000"; + +const KIND_REPOSITORY_ANNOUNCEMENT: u16 = 30617; +const KIND_REPOSITORY_STATE: u16 = 30618; + +/// Helper to connect to the relay +async fn connect() -> WsStream { + let (ws_stream, _) = connect_async(RELAY_URL) + .await + .expect("Failed to connect to relay"); + ws_stream +} + +/// Helper to send an event and get the response +async fn send_event(ws: &mut WsStream, event: nostr_sdk::Event) -> Value { + let event_msg = json!(["EVENT", event]); + ws.send(Message::Text(event_msg.to_string())) + .await + .expect("Failed to send event"); + + // Read response + if let Some(Ok(Message::Text(text))) = ws.next().await { + serde_json::from_str(&text).expect("Failed to parse response") + } else { + panic!("No response received"); + } +} + +/// Helper to create a repository announcement event +fn create_announcement( + keys: &Keys, + identifier: &str, + clone_urls: Vec<&str>, + relays: Vec<&str>, +) -> nostr_sdk::Event { + let mut tags = vec![Tag::custom(TagKind::D, vec![identifier.to_string()])]; + + for url in clone_urls { + tags.push(Tag::custom( + TagKind::Custom("clone".into()), + vec![url.to_string()], + )); + } + + for relay in relays { + tags.push(Tag::custom(TagKind::Relays, vec![relay.to_string()])); + } + + EventBuilder::new( + Kind::from(KIND_REPOSITORY_ANNOUNCEMENT), + "Test repository description", + tags, + ) + .sign_with_keys(keys) + .expect("Failed to sign event") +} + +/// Helper to create a repository state event +fn create_state(keys: &Keys, identifier: &str, branches: Vec<(&str, &str)>) -> nostr_sdk::Event { + let mut tags = vec![Tag::custom(TagKind::D, vec![identifier.to_string()])]; + + for (branch, commit) in branches { + tags.push(Tag::custom( + TagKind::Custom("ref".into()), + vec![format!("refs/heads/{}", branch), commit.to_string()], + )); + } + + EventBuilder::new(Kind::from(KIND_REPOSITORY_STATE), "", tags) + .sign_with_keys(keys) + .expect("Failed to sign event") +} + +/// GRASP-01, Line 9-10: MUST serve a NIP-01 compliant nostr relay at `/` +#[tokio::test] +#[ignore] // Requires relay to be running +async fn test_relay_accepts_connection() { + let _ws = connect().await; + // If we get here, connection succeeded +} + +/// GRASP-01, Line 11: MUST accept repository announcements (kind 30617) +#[tokio::test] +#[ignore] // Requires relay to be running +async fn test_accepts_valid_announcement() { + let mut ws = connect().await; + let keys = Keys::generate(); + + let event = create_announcement( + &keys, + "test-repo", + vec![&format!("https://{}/alice/test-repo.git", DOMAIN)], + vec![&format!("wss://{}", DOMAIN)], + ); + + let response = send_event(&mut ws, event.clone()).await; + + // Should be ["OK", event_id, true, ""] + assert_eq!(response[0], "OK"); + assert_eq!(response[1], event.id.to_hex()); + assert_eq!(response[2], true, "Event should be accepted"); +} + +/// GRASP-01, Line 12-13: MUST reject announcements that do not list the service +/// in both `clone` and `relays` tags +#[tokio::test] +#[ignore] // Requires relay to be running +async fn test_rejects_announcement_without_clone() { + let mut ws = connect().await; + let keys = Keys::generate(); + + // Missing clone tag + let event = create_announcement( + &keys, + "test-repo", + vec![], // No clone URLs + vec![&format!("wss://{}", DOMAIN)], + ); + + let response = send_event(&mut ws, event.clone()).await; + + // Should be rejected + assert_eq!(response[0], "OK"); + assert_eq!(response[1], event.id.to_hex()); + assert_eq!(response[2], false, "Event should be rejected"); + + let message = response[3].as_str().unwrap(); + assert!( + message.contains("clone") || message.contains("invalid"), + "Error message should mention clone requirement: {}", + message + ); +} + +/// GRASP-01, Line 12-13: MUST reject announcements that do not list the service +/// in both `clone` and `relays` tags +#[tokio::test] +#[ignore] // Requires relay to be running +async fn test_rejects_announcement_without_relay() { + let mut ws = connect().await; + let keys = Keys::generate(); + + // Missing relay tag + let event = create_announcement( + &keys, + "test-repo", + vec![&format!("https://{}/alice/test-repo.git", DOMAIN)], + vec![], // No relays + ); + + let response = send_event(&mut ws, event.clone()).await; + + // Should be rejected + assert_eq!(response[0], "OK"); + assert_eq!(response[1], event.id.to_hex()); + assert_eq!(response[2], false, "Event should be rejected"); + + let message = response[3].as_str().unwrap(); + assert!( + message.contains("relays") || message.contains("invalid"), + "Error message should mention relay requirement: {}", + message + ); +} + +/// GRASP-01, Line 12-13: MUST reject announcements listing other services +#[tokio::test] +#[ignore] // Requires relay to be running +async fn test_rejects_announcement_for_other_service() { + let mut ws = connect().await; + let keys = Keys::generate(); + + // Lists different service + let event = create_announcement( + &keys, + "test-repo", + vec!["https://other-service.com/alice/test-repo.git"], + vec!["wss://other-service.com"], + ); + + let response = send_event(&mut ws, event.clone()).await; + + // Should be rejected + assert_eq!(response[0], "OK"); + assert_eq!(response[1], event.id.to_hex()); + assert_eq!(response[2], false, "Event should be rejected"); +} + +/// GRASP-01, Line 11: MUST accept repository state announcements (kind 30618) +#[tokio::test] +#[ignore] // Requires relay to be running +async fn test_accepts_valid_state() { + let mut ws = connect().await; + let keys = Keys::generate(); + + let event = create_state( + &keys, + "test-repo", + vec![("main", "a1b2c3d4e5f6789012345678901234567890abcd")], + ); + + let response = send_event(&mut ws, event.clone()).await; + + // Should be accepted + assert_eq!(response[0], "OK"); + assert_eq!(response[1], event.id.to_hex()); + assert_eq!(response[2], true, "State event should be accepted"); +} + +/// Test state event with multiple branches +#[tokio::test] +#[ignore] // Requires relay to be running +async fn test_accepts_state_with_multiple_branches() { + let mut ws = connect().await; + let keys = Keys::generate(); + + let event = create_state( + &keys, + "test-repo", + vec![ + ("main", "a1b2c3d4e5f6789012345678901234567890abcd"), + ("develop", "b2c3d4e5f6789012345678901234567890abcde"), + ("feature-x", "c3d4e5f6789012345678901234567890abcdef1"), + ], + ); + + let response = send_event(&mut ws, event.clone()).await; + + assert_eq!(response[0], "OK"); + assert_eq!(response[2], true, "State event should be accepted"); +} + +/// Test state event without identifier should be rejected +#[tokio::test] +#[ignore] // Requires relay to be running +async fn test_rejects_state_without_identifier() { + let mut ws = connect().await; + let keys = Keys::generate(); + + // Create state without identifier + let event = EventBuilder::new(Kind::from(KIND_REPOSITORY_STATE), "", vec![]) + .sign_with_keys(&keys) + .expect("Failed to sign event"); + + let response = send_event(&mut ws, event.clone()).await; + + // Should be rejected + assert_eq!(response[0], "OK"); + assert_eq!(response[1], event.id.to_hex()); + assert_eq!(response[2], false, "Event should be rejected"); + + let message = response[3].as_str().unwrap(); + assert!( + message.contains("identifier") || message.contains("invalid"), + "Error message should mention identifier requirement: {}", + message + ); +} + +/// Test querying for announcements +#[tokio::test] +#[ignore] // Requires relay to be running +async fn test_query_announcements() { + let mut ws = connect().await; + let keys = Keys::generate(); + + // Send an announcement + let event = create_announcement( + &keys, + "query-test-repo", + vec![&format!("https://{}/alice/query-test-repo.git", DOMAIN)], + vec![&format!("wss://{}", DOMAIN)], + ); + + send_event(&mut ws, event.clone()).await; + + // Query for announcements + let req = json!([ + "REQ", + "test-sub", + { + "kinds": [KIND_REPOSITORY_ANNOUNCEMENT], + "authors": [keys.public_key().to_hex()] + } + ]); + + ws.send(Message::Text(req.to_string())) + .await + .expect("Failed to send REQ"); + + // Read responses + let mut found_event = false; + let mut got_eose = false; + + for _ in 0..10 { + if let Some(Ok(Message::Text(text))) = ws.next().await { + let response: Value = serde_json::from_str(&text).expect("Failed to parse"); + + if response[0] == "EVENT" { + assert_eq!(response[1], "test-sub"); + found_event = true; + } else if response[0] == "EOSE" { + assert_eq!(response[1], "test-sub"); + got_eose = true; + break; + } + } + } + + assert!(found_event, "Should have received the announcement"); + assert!(got_eose, "Should have received EOSE"); +} + +/// Test querying for state events +#[tokio::test] +#[ignore] // Requires relay to be running +async fn test_query_states() { + let mut ws = connect().await; + let keys = Keys::generate(); + + // Send a state event + let event = create_state( + &keys, + "query-test-repo", + vec![("main", "a1b2c3d4e5f6789012345678901234567890abcd")], + ); + + send_event(&mut ws, event.clone()).await; + + // Query for states + let req = json!([ + "REQ", + "test-sub", + { + "kinds": [KIND_REPOSITORY_STATE], + "authors": [keys.public_key().to_hex()] + } + ]); + + ws.send(Message::Text(req.to_string())) + .await + .expect("Failed to send REQ"); + + // Read responses + let mut found_event = false; + let mut got_eose = false; + + for _ in 0..10 { + if let Some(Ok(Message::Text(text))) = ws.next().await { + let response: Value = serde_json::from_str(&text).expect("Failed to parse"); + + if response[0] == "EVENT" { + assert_eq!(response[1], "test-sub"); + found_event = true; + } else if response[0] == "EOSE" { + assert_eq!(response[1], "test-sub"); + got_eose = true; + break; + } + } + } + + assert!(found_event, "Should have received the state event"); + assert!(got_eose, "Should have received EOSE"); +} + +/// Test duplicate event handling +#[tokio::test] +#[ignore] // Requires relay to be running +async fn test_duplicate_announcement() { + let mut ws = connect().await; + let keys = Keys::generate(); + + let event = create_announcement( + &keys, + "duplicate-test", + vec![&format!("https://{}/alice/duplicate-test.git", DOMAIN)], + vec![&format!("wss://{}", DOMAIN)], + ); + + // Send first time + let response1 = send_event(&mut ws, event.clone()).await; + assert_eq!(response1[2], true, "First send should succeed"); + + // Send second time (duplicate) + let response2 = send_event(&mut ws, event.clone()).await; + assert_eq!(response2[2], true, "Duplicate should be acknowledged"); + + let message = response2[3].as_str().unwrap(); + assert!( + message.contains("duplicate") || message.is_empty(), + "Should indicate duplicate: {}", + message + ); +} -- cgit v1.2.3