From 652c5913f695ba7e8dfd78cd0cbe5cc3de67fa59 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Tue, 4 Nov 2025 21:58:23 +0000 Subject: test: migrate to TestRelay fixture pattern and add compliance docs - Remove unnecessary 'nix' dev dependency (Unix syscalls crate, not needed) - Migrate announcement tests to new TestRelay fixture pattern - Delete legacy test files (announcement_tests.rs, test_relay.sh) - Add comprehensive test documentation (docs/how-to/test-compliance.md) - Update README.md with new test commands - All 18 integration tests passing (NIP-01 + NIP-34) Benefits: - Automatic relay lifecycle management - No manual setup required - Pure Rust integration tests - Better developer experience - CI/CD ready --- tests/nip34_announcements.rs | 549 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 549 insertions(+) create mode 100644 tests/nip34_announcements.rs (limited to 'tests/nip34_announcements.rs') diff --git a/tests/nip34_announcements.rs b/tests/nip34_announcements.rs new file mode 100644 index 0000000..6e83bb6 --- /dev/null +++ b/tests/nip34_announcements.rs @@ -0,0 +1,549 @@ +//! NIP-34 Repository Announcements Integration Tests (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 +//! +//! # Test Strategy +//! +//! - Uses TestRelay fixture for automatic relay lifecycle management +//! - Pure Rust, no shell scripts +//! - Tests run in parallel with isolated relay instances +//! +//! # Running Tests +//! +//! ```bash +//! # Run all NIP-34 announcement tests +//! cargo test --test nip34_announcements +//! +//! # Run specific test +//! cargo test --test nip34_announcements test_accepts_valid_announcement +//! +//! # With output +//! cargo test --test nip34_announcements -- --nocapture +//! ``` + +mod common; + +use common::TestRelay; +use futures_util::{SinkExt, StreamExt}; +use nostr_sdk::{EventBuilder, Keys, Kind, Tag, TagKind}; +use serde_json::{json, Value}; +use tokio_tungstenite::{connect_async, tungstenite::Message}; + +const KIND_REPOSITORY_ANNOUNCEMENT: u16 = 30617; +const KIND_REPOSITORY_STATE: u16 = 30618; + +/// Helper to connect to a test relay +async fn connect_to_relay(url: &str) -> tokio_tungstenite::WebSocketStream> { + let (ws, _) = connect_async(url) + .await + .expect("Failed to connect to relay"); + ws +} + +/// Helper to create a repository announcement event +fn create_announcement( + keys: &Keys, + _domain: &str, + identifier: &str, + clone_urls: Vec, + relays: Vec, +) -> 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::Clone, + vec![url], + )); + } + + for relay in relays { + tags.push(Tag::custom(TagKind::Relays, vec![relay])); + } + + EventBuilder::new( + Kind::from(KIND_REPOSITORY_ANNOUNCEMENT), + "Test repository description", + ) + .tags(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(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] +async fn test_relay_accepts_connection() { + let relay = TestRelay::start().await; + + // Try to connect + let ws = connect_to_relay(relay.url()).await; + + drop(ws); // Clean disconnect +} + +/// GRASP-01, Line 11: MUST accept repository announcements (kind 30617) +#[tokio::test] +async fn test_accepts_valid_announcement() { + let relay = TestRelay::start().await; + let keys = Keys::generate(); + + let mut ws = connect_to_relay(relay.url()).await; + + let event = create_announcement( + &keys, + &relay.domain(), + "test-repo", + vec![format!("https://{}/alice/test-repo.git", relay.domain())], + vec![format!("wss://{}", relay.domain())], + ); + + // Send event + 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 { + let response: Value = serde_json::from_str(&text).expect("Failed to parse response"); + + // Should be ["OK", event_id, true, ""] + assert_eq!(response[0], "OK"); + assert_eq!(response[1], event.id.to_hex()); + if response[2] != true { + eprintln!("Event rejected: {}", response[3]); + } + assert_eq!(response[2], true, "Event should be accepted"); + } else { + panic!("No response received"); + } +} + +/// GRASP-01, Line 12-13: MUST reject announcements that do not list the service +/// in both `clone` and `relays` tags +#[tokio::test] +async fn test_rejects_announcement_without_clone() { + let relay = TestRelay::start().await; + let keys = Keys::generate(); + + let (mut ws, _) = connect_async(relay.url()) + .await + .expect("Failed to connect"); + + // Missing clone tag + let event = create_announcement( + &keys, + &relay.domain(), + "test-repo", + vec![], // No clone URLs + vec![format!("wss://{}", relay.domain())], + ); + + let event_msg = json!(["EVENT", event]); + ws.send(Message::Text(event_msg.to_string())) + .await + .expect("Failed to send event"); + + if let Some(Ok(Message::Text(text))) = ws.next().await { + let response: Value = serde_json::from_str(&text).expect("Failed to parse"); + + // 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 + ); + } else { + panic!("No response received"); + } +} + +/// GRASP-01, Line 12-13: MUST reject announcements that do not list the service +/// in both `clone` and `relays` tags +#[tokio::test] +async fn test_rejects_announcement_without_relay() { + let relay = TestRelay::start().await; + let keys = Keys::generate(); + + let (mut ws, _) = connect_async(relay.url()) + .await + .expect("Failed to connect"); + + // Missing relay tag + let event = create_announcement( + &keys, + &relay.domain(), + "test-repo", + vec![format!("https://{}/alice/test-repo.git", relay.domain())], + vec![], // No relays + ); + + let event_msg = json!(["EVENT", event]); + ws.send(Message::Text(event_msg.to_string())) + .await + .expect("Failed to send event"); + + if let Some(Ok(Message::Text(text))) = ws.next().await { + let response: Value = serde_json::from_str(&text).expect("Failed to parse"); + + // 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 + ); + } else { + panic!("No response received"); + } +} + +/// GRASP-01, Line 12-13: MUST reject announcements listing other services +#[tokio::test] +async fn test_rejects_announcement_for_other_service() { + let relay = TestRelay::start().await; + let keys = Keys::generate(); + + let (mut ws, _) = connect_async(relay.url()) + .await + .expect("Failed to connect"); + + // Lists different service + let event = create_announcement( + &keys, + &relay.domain(), + "test-repo", + vec!["https://other-service.com/alice/test-repo.git".to_string()], + vec!["wss://other-service.com".to_string()], + ); + + let event_msg = json!(["EVENT", event]); + ws.send(Message::Text(event_msg.to_string())) + .await + .expect("Failed to send event"); + + if let Some(Ok(Message::Text(text))) = ws.next().await { + let response: Value = serde_json::from_str(&text).expect("Failed to parse"); + + // 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"); + } else { + panic!("No response received"); + } +} + +/// GRASP-01, Line 11: MUST accept repository state announcements (kind 30618) +#[tokio::test] +async fn test_accepts_valid_state() { + let relay = TestRelay::start().await; + let keys = Keys::generate(); + + let (mut ws, _) = connect_async(relay.url()) + .await + .expect("Failed to connect"); + + let event = create_state( + &keys, + "test-repo", + vec![("main", "a1b2c3d4e5f6789012345678901234567890abcd")], + ); + + let event_msg = json!(["EVENT", event]); + ws.send(Message::Text(event_msg.to_string())) + .await + .expect("Failed to send event"); + + if let Some(Ok(Message::Text(text))) = ws.next().await { + let response: Value = serde_json::from_str(&text).expect("Failed to parse"); + + // 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"); + } else { + panic!("No response received"); + } +} + +/// Test state event with multiple branches +#[tokio::test] +async fn test_accepts_state_with_multiple_branches() { + let relay = TestRelay::start().await; + let keys = Keys::generate(); + + let (mut ws, _) = connect_async(relay.url()) + .await + .expect("Failed to connect"); + + let event = create_state( + &keys, + "test-repo", + vec![ + ("main", "a1b2c3d4e5f6789012345678901234567890abcd"), + ("develop", "b2c3d4e5f6789012345678901234567890abcde"), + ("feature-x", "c3d4e5f6789012345678901234567890abcdef1"), + ], + ); + + let event_msg = json!(["EVENT", event]); + ws.send(Message::Text(event_msg.to_string())) + .await + .expect("Failed to send event"); + + if let Some(Ok(Message::Text(text))) = ws.next().await { + let response: Value = serde_json::from_str(&text).expect("Failed to parse"); + + assert_eq!(response[0], "OK"); + assert_eq!(response[2], true, "State event should be accepted"); + } else { + panic!("No response received"); + } +} + +/// Test state event without identifier should be rejected +#[tokio::test] +async fn test_rejects_state_without_identifier() { + let relay = TestRelay::start().await; + let keys = Keys::generate(); + + let (mut ws, _) = connect_async(relay.url()) + .await + .expect("Failed to connect"); + + // Create state without identifier + let event = EventBuilder::new(Kind::from(KIND_REPOSITORY_STATE), "") + .sign_with_keys(&keys) + .expect("Failed to sign event"); + + let event_msg = json!(["EVENT", event]); + ws.send(Message::Text(event_msg.to_string())) + .await + .expect("Failed to send event"); + + if let Some(Ok(Message::Text(text))) = ws.next().await { + let response: Value = serde_json::from_str(&text).expect("Failed to parse"); + + // 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 + ); + } else { + panic!("No response received"); + } +} + +/// Test querying for announcements +#[tokio::test] +async fn test_query_announcements() { + let relay = TestRelay::start().await; + let keys = Keys::generate(); + + let (mut ws, _) = connect_async(relay.url()) + .await + .expect("Failed to connect"); + + // Send an announcement + let event = create_announcement( + &keys, + &relay.domain(), + "query-test-repo", + vec![format!("https://{}/alice/query-test-repo.git", relay.domain())], + vec![format!("wss://{}", relay.domain())], + ); + + let event_msg = json!(["EVENT", event]); + ws.send(Message::Text(event_msg.to_string())) + .await + .expect("Failed to send event"); + + // Wait for OK response + if let Some(Ok(Message::Text(_))) = ws.next().await { + // Got OK response + } + + // 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] +async fn test_query_states() { + let relay = TestRelay::start().await; + let keys = Keys::generate(); + + let (mut ws, _) = connect_async(relay.url()) + .await + .expect("Failed to connect"); + + // Send a state event + let event = create_state( + &keys, + "query-test-repo", + vec![("main", "a1b2c3d4e5f6789012345678901234567890abcd")], + ); + + let event_msg = json!(["EVENT", event]); + ws.send(Message::Text(event_msg.to_string())) + .await + .expect("Failed to send event"); + + // Wait for OK response + if let Some(Ok(Message::Text(_))) = ws.next().await { + // Got OK response + } + + // 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] +async fn test_duplicate_announcement() { + let relay = TestRelay::start().await; + let keys = Keys::generate(); + + let (mut ws, _) = connect_async(relay.url()) + .await + .expect("Failed to connect"); + + let event = create_announcement( + &keys, + &relay.domain(), + "duplicate-test", + vec![format!("https://{}/alice/duplicate-test.git", relay.domain())], + vec![format!("wss://{}", relay.domain())], + ); + + // Send first time + let event_msg = json!(["EVENT", event]); + ws.send(Message::Text(event_msg.to_string())) + .await + .expect("Failed to send event"); + + if let Some(Ok(Message::Text(text))) = ws.next().await { + let response1: Value = serde_json::from_str(&text).expect("Failed to parse"); + assert_eq!(response1[2], true, "First send should succeed"); + } + + // Send second time (duplicate) + let event_msg = json!(["EVENT", event]); + ws.send(Message::Text(event_msg.to_string())) + .await + .expect("Failed to send event"); + + if let Some(Ok(Message::Text(text))) = ws.next().await { + let response2: Value = serde_json::from_str(&text).expect("Failed to parse"); + 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