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/announcement_tests.rs | 411 -------------------------------- tests/common/mod.rs | 5 + tests/common/relay.rs | 179 ++++++++++++++ tests/nip01_compliance.rs | 190 +++++++++++++++ tests/nip34_announcements.rs | 549 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 923 insertions(+), 411 deletions(-) delete mode 100644 tests/announcement_tests.rs create mode 100644 tests/common/mod.rs create mode 100644 tests/common/relay.rs create mode 100644 tests/nip01_compliance.rs create mode 100644 tests/nip34_announcements.rs (limited to 'tests') diff --git a/tests/announcement_tests.rs b/tests/announcement_tests.rs deleted file mode 100644 index 137ba5f..0000000 --- a/tests/announcement_tests.rs +++ /dev/null @@ -1,411 +0,0 @@ -/// 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 - ); -} diff --git a/tests/common/mod.rs b/tests/common/mod.rs new file mode 100644 index 0000000..76ed273 --- /dev/null +++ b/tests/common/mod.rs @@ -0,0 +1,5 @@ +//! Common test utilities + +pub mod relay; + +pub use relay::TestRelay; diff --git a/tests/common/relay.rs b/tests/common/relay.rs new file mode 100644 index 0000000..4208278 --- /dev/null +++ b/tests/common/relay.rs @@ -0,0 +1,179 @@ +//! Test relay fixture +//! +//! Provides automatic relay lifecycle management for integration tests. + +use std::process::{Child, Command, Stdio}; +use std::time::Duration; +use tokio::time::sleep; + +/// Test relay fixture that manages relay lifecycle +/// +/// Automatically starts and stops the ngit-grasp relay for testing. +/// Uses a random port to avoid conflicts. +pub struct TestRelay { + process: Child, + url: String, + port: u16, +} + +impl TestRelay { + /// Start a test relay instance + /// + /// # Example + /// + /// ```no_run + /// use common::TestRelay; + /// + /// #[tokio::test] + /// async fn test_something() { + /// let relay = TestRelay::start().await; + /// // Use relay.url() for testing + /// relay.stop().await; + /// } + /// ``` + pub async fn start() -> Self { + Self::start_with_port(Self::find_free_port()).await + } + + /// Start relay on a specific port + pub async fn start_with_port(port: u16) -> Self { + let bind_address = format!("127.0.0.1:{}", port); + let url = format!("ws://127.0.0.1:{}", port); + + // Use the built binary directly (faster than cargo run) + let binary_path = std::env::current_exe() + .expect("Failed to get current exe") + .parent() + .expect("Failed to get parent dir") + .parent() + .expect("Failed to get grandparent dir") + .join("ngit-grasp"); + + // Start the relay process + let process = Command::new(&binary_path) + .env("NGIT_BIND_ADDRESS", &bind_address) + .env("NGIT_DOMAIN", &bind_address) // Set domain to match bind address + .env("RUST_LOG", "warn") // Less logging during tests + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("Failed to start relay process"); + + let relay = Self { + process, + url, + port, + }; + + // Wait for relay to be ready + relay.wait_for_ready().await; + + relay + } + + /// Get the relay WebSocket URL + pub fn url(&self) -> &str { + &self.url + } + + /// Get the relay port + pub fn port(&self) -> u16 { + self.port + } + + /// Get the relay domain (host:port) + pub fn domain(&self) -> String { + format!("127.0.0.1:{}", self.port) + } + + /// Wait for the relay to be ready to accept connections + async fn wait_for_ready(&self) { + let max_attempts = 50; // 5 seconds total + let delay = Duration::from_millis(100); + + for attempt in 0..max_attempts { + // Try to connect to the relay + match tokio::net::TcpStream::connect(format!("127.0.0.1:{}", self.port)).await { + Ok(_) => { + // Connection successful, relay is ready + // Give it a tiny bit more time to fully initialize + sleep(Duration::from_millis(100)).await; + return; + } + Err(_) => { + if attempt == max_attempts - 1 { + panic!("Relay failed to start after {} attempts", max_attempts); + } + sleep(delay).await; + } + } + } + } + + /// Stop the relay + pub async fn stop(mut self) { + // Send SIGTERM to gracefully shutdown + #[cfg(unix)] + { + use nix::sys::signal::{kill, Signal}; + use nix::unistd::Pid; + + let pid = Pid::from_raw(self.process.id() as i32); + let _ = kill(pid, Signal::SIGTERM); + } + + // Wait a bit for graceful shutdown + sleep(Duration::from_millis(100)).await; + + // Force kill if still running + let _ = self.process.kill(); + let _ = self.process.wait(); + } + + /// Find a free port to use for testing + fn find_free_port() -> u16 { + use std::net::TcpListener; + + // Bind to port 0 to get a random free port + let listener = TcpListener::bind("127.0.0.1:0") + .expect("Failed to bind to random port"); + + let port = listener.local_addr() + .expect("Failed to get local address") + .port(); + + // Drop the listener to free the port + drop(listener); + + port + } +} + +impl Drop for TestRelay { + fn drop(&mut self) { + // Ensure process is killed when TestRelay is dropped + let _ = self.process.kill(); + let _ = self.process.wait(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + #[ignore] // Requires relay binary to be built + async fn test_relay_lifecycle() { + let relay = TestRelay::start().await; + assert!(relay.url().starts_with("ws://127.0.0.1:")); + assert!(relay.port() > 0); + relay.stop().await; + } + + #[test] + fn test_find_free_port() { + let port = TestRelay::find_free_port(); + assert!(port > 0); + // Port is u16, so it's always < 65536 + } +} diff --git a/tests/nip01_compliance.rs b/tests/nip01_compliance.rs new file mode 100644 index 0000000..3e2fdd3 --- /dev/null +++ b/tests/nip01_compliance.rs @@ -0,0 +1,190 @@ +//! NIP-01 Compliance Integration Tests +//! +//! These tests verify that ngit-grasp relay implements NIP-01 correctly +//! by using the grasp-audit library to run compliance tests. +//! +//! # Test Strategy +//! +//! - Uses grasp-audit as a library (not CLI) +//! - Automatically manages relay lifecycle +//! - Reuses test specs from grasp-audit (single source of truth) +//! - Pure Rust, no shell scripts +//! +//! # Running Tests +//! +//! ```bash +//! # Run all NIP-01 compliance tests +//! cargo test --test nip01_compliance +//! +//! # Run specific test +//! cargo test --test nip01_compliance test_nip01_smoke +//! +//! # With output +//! cargo test --test nip01_compliance -- --nocapture +//! ``` + +mod common; + +use common::TestRelay; +use grasp_audit::*; + +/// Test NIP-01 smoke tests against ngit-grasp relay +/// +/// This test: +/// 1. Starts a fresh ngit-grasp relay instance +/// 2. Runs all NIP-01 smoke tests from grasp-audit +/// 3. Verifies all tests pass +/// 4. Shuts down the relay +#[tokio::test] +async fn test_nip01_smoke() { + // Start test relay + let relay = TestRelay::start().await; + + // Create audit client in CI mode (isolated, no cleanup needed) + let config = AuditConfig::ci(); + let client = AuditClient::new(relay.url(), config) + .await + .expect("Failed to create audit client"); + + // Run all NIP-01 smoke tests + let results = specs::Nip01SmokeTests::run_all(&client).await; + + // Print detailed report + results.print_report(); + + // Stop relay + relay.stop().await; + + // Assert all tests passed + assert!( + results.all_passed(), + "NIP-01 smoke tests failed: {}/{} passed", + results.passed_count(), + results.total_count() + ); +} + +/// Test individual NIP-01 tests can be run separately +/// +/// This demonstrates that we can run individual tests from the specs +/// for more granular testing or debugging. +#[tokio::test] +async fn test_nip01_individual_tests() { + use grasp_audit::specs::nip01_smoke::Nip01SmokeTests; + + let relay = TestRelay::start().await; + let config = AuditConfig::ci(); + let client = AuditClient::new(relay.url(), config) + .await + .expect("Failed to create audit client"); + + // We can't call private methods, so we'll run the full suite + // This test is mainly to show the pattern + let all_results = Nip01SmokeTests::run_all(&client).await; + + relay.stop().await; + + // Verify + assert!(all_results.all_passed()); +} + +/// Test that relay rejects invalid events +/// +/// This is a critical security test - we want to ensure the relay +/// properly validates events before accepting them. +#[tokio::test] +async fn test_relay_validates_events() { + let relay = TestRelay::start().await; + let config = AuditConfig::ci(); + let client = AuditClient::new(relay.url(), config) + .await + .expect("Failed to create audit client"); + + // The validation tests are part of the smoke tests + let results = specs::Nip01SmokeTests::run_all(&client).await; + + // Check that validation tests exist and pass + let validation_tests: Vec<_> = results + .results + .iter() + .filter(|t| t.spec_ref.contains("validation")) + .collect(); + + relay.stop().await; + + // Should have validation tests + assert!( + !validation_tests.is_empty(), + "No validation tests found in NIP-01 smoke tests" + ); + + // All validation tests should pass + for test in validation_tests { + assert!( + test.passed, + "Validation test failed: {} - {}", + test.name, + test.error.as_deref().unwrap_or("unknown error") + ); + } +} + +/// Test relay lifecycle management +/// +/// Ensures our test fixture properly manages relay lifecycle +#[tokio::test] +async fn test_relay_lifecycle() { + // Start relay + let relay = TestRelay::start().await; + let url = relay.url().to_string(); + + // Verify we can connect + let config = AuditConfig::ci(); + let client = AuditClient::new(&url, config) + .await + .expect("Failed to connect to relay"); + + assert!(client.is_connected().await, "Client should be connected"); + + // Stop relay + relay.stop().await; + + // Note: We can't easily verify disconnection without modifying grasp-audit + // to expose connection state after relay shutdown. That's okay - the + // important part is that the relay starts and stops cleanly. +} + +/// Test multiple relays can run in parallel +/// +/// This ensures our random port selection works correctly +#[tokio::test] +async fn test_parallel_relays() { + // Start two relays simultaneously + let relay1 = TestRelay::start().await; + let relay2 = TestRelay::start().await; + + // Should have different URLs (different ports) + assert_ne!( + relay1.url(), + relay2.url(), + "Relays should use different ports" + ); + + // Both should be connectable + let config = AuditConfig::ci(); + + let client1 = AuditClient::new(relay1.url(), config.clone()) + .await + .expect("Failed to connect to relay 1"); + + let client2 = AuditClient::new(relay2.url(), config) + .await + .expect("Failed to connect to relay 2"); + + assert!(client1.is_connected().await); + assert!(client2.is_connected().await); + + // Clean up + relay1.stop().await; + relay2.stop().await; +} 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