upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/announcement_tests.rs411
-rw-r--r--tests/common/mod.rs5
-rw-r--r--tests/common/relay.rs179
-rw-r--r--tests/nip01_compliance.rs190
-rw-r--r--tests/nip34_announcements.rs549
5 files changed, 923 insertions, 411 deletions
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 @@
1/// Integration tests for NIP-34 Repository Announcements (GRASP-01)
2///
3/// Tests the acceptance and validation of repository announcements (kind 30617)
4/// and repository state announcements (kind 30618) according to GRASP-01.
5///
6/// Reference: GRASP-01, Lines 9-20
7
8use futures_util::{SinkExt, StreamExt};
9use nostr_sdk::{EventBuilder, Keys, Kind, Tag, TagKind};
10use serde_json::{json, Value};
11use tokio::net::TcpStream;
12use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream};
13
14type WsStream = WebSocketStream<MaybeTlsStream<TcpStream>>;
15
16const RELAY_URL: &str = "ws://127.0.0.1:7000";
17const DOMAIN: &str = "127.0.0.1:7000";
18
19const KIND_REPOSITORY_ANNOUNCEMENT: u16 = 30617;
20const KIND_REPOSITORY_STATE: u16 = 30618;
21
22/// Helper to connect to the relay
23async fn connect() -> WsStream {
24 let (ws_stream, _) = connect_async(RELAY_URL)
25 .await
26 .expect("Failed to connect to relay");
27 ws_stream
28}
29
30/// Helper to send an event and get the response
31async fn send_event(ws: &mut WsStream, event: nostr_sdk::Event) -> Value {
32 let event_msg = json!(["EVENT", event]);
33 ws.send(Message::Text(event_msg.to_string()))
34 .await
35 .expect("Failed to send event");
36
37 // Read response
38 if let Some(Ok(Message::Text(text))) = ws.next().await {
39 serde_json::from_str(&text).expect("Failed to parse response")
40 } else {
41 panic!("No response received");
42 }
43}
44
45/// Helper to create a repository announcement event
46fn create_announcement(
47 keys: &Keys,
48 identifier: &str,
49 clone_urls: Vec<&str>,
50 relays: Vec<&str>,
51) -> nostr_sdk::Event {
52 let mut tags = vec![Tag::custom(TagKind::D, vec![identifier.to_string()])];
53
54 for url in clone_urls {
55 tags.push(Tag::custom(
56 TagKind::Custom("clone".into()),
57 vec![url.to_string()],
58 ));
59 }
60
61 for relay in relays {
62 tags.push(Tag::custom(TagKind::Relays, vec![relay.to_string()]));
63 }
64
65 EventBuilder::new(
66 Kind::from(KIND_REPOSITORY_ANNOUNCEMENT),
67 "Test repository description",
68 tags,
69 )
70 .sign_with_keys(keys)
71 .expect("Failed to sign event")
72}
73
74/// Helper to create a repository state event
75fn create_state(keys: &Keys, identifier: &str, branches: Vec<(&str, &str)>) -> nostr_sdk::Event {
76 let mut tags = vec![Tag::custom(TagKind::D, vec![identifier.to_string()])];
77
78 for (branch, commit) in branches {
79 tags.push(Tag::custom(
80 TagKind::Custom("ref".into()),
81 vec![format!("refs/heads/{}", branch), commit.to_string()],
82 ));
83 }
84
85 EventBuilder::new(Kind::from(KIND_REPOSITORY_STATE), "", tags)
86 .sign_with_keys(keys)
87 .expect("Failed to sign event")
88}
89
90/// GRASP-01, Line 9-10: MUST serve a NIP-01 compliant nostr relay at `/`
91#[tokio::test]
92#[ignore] // Requires relay to be running
93async fn test_relay_accepts_connection() {
94 let _ws = connect().await;
95 // If we get here, connection succeeded
96}
97
98/// GRASP-01, Line 11: MUST accept repository announcements (kind 30617)
99#[tokio::test]
100#[ignore] // Requires relay to be running
101async fn test_accepts_valid_announcement() {
102 let mut ws = connect().await;
103 let keys = Keys::generate();
104
105 let event = create_announcement(
106 &keys,
107 "test-repo",
108 vec![&format!("https://{}/alice/test-repo.git", DOMAIN)],
109 vec![&format!("wss://{}", DOMAIN)],
110 );
111
112 let response = send_event(&mut ws, event.clone()).await;
113
114 // Should be ["OK", event_id, true, ""]
115 assert_eq!(response[0], "OK");
116 assert_eq!(response[1], event.id.to_hex());
117 assert_eq!(response[2], true, "Event should be accepted");
118}
119
120/// GRASP-01, Line 12-13: MUST reject announcements that do not list the service
121/// in both `clone` and `relays` tags
122#[tokio::test]
123#[ignore] // Requires relay to be running
124async fn test_rejects_announcement_without_clone() {
125 let mut ws = connect().await;
126 let keys = Keys::generate();
127
128 // Missing clone tag
129 let event = create_announcement(
130 &keys,
131 "test-repo",
132 vec![], // No clone URLs
133 vec![&format!("wss://{}", DOMAIN)],
134 );
135
136 let response = send_event(&mut ws, event.clone()).await;
137
138 // Should be rejected
139 assert_eq!(response[0], "OK");
140 assert_eq!(response[1], event.id.to_hex());
141 assert_eq!(response[2], false, "Event should be rejected");
142
143 let message = response[3].as_str().unwrap();
144 assert!(
145 message.contains("clone") || message.contains("invalid"),
146 "Error message should mention clone requirement: {}",
147 message
148 );
149}
150
151/// GRASP-01, Line 12-13: MUST reject announcements that do not list the service
152/// in both `clone` and `relays` tags
153#[tokio::test]
154#[ignore] // Requires relay to be running
155async fn test_rejects_announcement_without_relay() {
156 let mut ws = connect().await;
157 let keys = Keys::generate();
158
159 // Missing relay tag
160 let event = create_announcement(
161 &keys,
162 "test-repo",
163 vec![&format!("https://{}/alice/test-repo.git", DOMAIN)],
164 vec![], // No relays
165 );
166
167 let response = send_event(&mut ws, event.clone()).await;
168
169 // Should be rejected
170 assert_eq!(response[0], "OK");
171 assert_eq!(response[1], event.id.to_hex());
172 assert_eq!(response[2], false, "Event should be rejected");
173
174 let message = response[3].as_str().unwrap();
175 assert!(
176 message.contains("relays") || message.contains("invalid"),
177 "Error message should mention relay requirement: {}",
178 message
179 );
180}
181
182/// GRASP-01, Line 12-13: MUST reject announcements listing other services
183#[tokio::test]
184#[ignore] // Requires relay to be running
185async fn test_rejects_announcement_for_other_service() {
186 let mut ws = connect().await;
187 let keys = Keys::generate();
188
189 // Lists different service
190 let event = create_announcement(
191 &keys,
192 "test-repo",
193 vec!["https://other-service.com/alice/test-repo.git"],
194 vec!["wss://other-service.com"],
195 );
196
197 let response = send_event(&mut ws, event.clone()).await;
198
199 // Should be rejected
200 assert_eq!(response[0], "OK");
201 assert_eq!(response[1], event.id.to_hex());
202 assert_eq!(response[2], false, "Event should be rejected");
203}
204
205/// GRASP-01, Line 11: MUST accept repository state announcements (kind 30618)
206#[tokio::test]
207#[ignore] // Requires relay to be running
208async fn test_accepts_valid_state() {
209 let mut ws = connect().await;
210 let keys = Keys::generate();
211
212 let event = create_state(
213 &keys,
214 "test-repo",
215 vec![("main", "a1b2c3d4e5f6789012345678901234567890abcd")],
216 );
217
218 let response = send_event(&mut ws, event.clone()).await;
219
220 // Should be accepted
221 assert_eq!(response[0], "OK");
222 assert_eq!(response[1], event.id.to_hex());
223 assert_eq!(response[2], true, "State event should be accepted");
224}
225
226/// Test state event with multiple branches
227#[tokio::test]
228#[ignore] // Requires relay to be running
229async fn test_accepts_state_with_multiple_branches() {
230 let mut ws = connect().await;
231 let keys = Keys::generate();
232
233 let event = create_state(
234 &keys,
235 "test-repo",
236 vec![
237 ("main", "a1b2c3d4e5f6789012345678901234567890abcd"),
238 ("develop", "b2c3d4e5f6789012345678901234567890abcde"),
239 ("feature-x", "c3d4e5f6789012345678901234567890abcdef1"),
240 ],
241 );
242
243 let response = send_event(&mut ws, event.clone()).await;
244
245 assert_eq!(response[0], "OK");
246 assert_eq!(response[2], true, "State event should be accepted");
247}
248
249/// Test state event without identifier should be rejected
250#[tokio::test]
251#[ignore] // Requires relay to be running
252async fn test_rejects_state_without_identifier() {
253 let mut ws = connect().await;
254 let keys = Keys::generate();
255
256 // Create state without identifier
257 let event = EventBuilder::new(Kind::from(KIND_REPOSITORY_STATE), "", vec![])
258 .sign_with_keys(&keys)
259 .expect("Failed to sign event");
260
261 let response = send_event(&mut ws, event.clone()).await;
262
263 // Should be rejected
264 assert_eq!(response[0], "OK");
265 assert_eq!(response[1], event.id.to_hex());
266 assert_eq!(response[2], false, "Event should be rejected");
267
268 let message = response[3].as_str().unwrap();
269 assert!(
270 message.contains("identifier") || message.contains("invalid"),
271 "Error message should mention identifier requirement: {}",
272 message
273 );
274}
275
276/// Test querying for announcements
277#[tokio::test]
278#[ignore] // Requires relay to be running
279async fn test_query_announcements() {
280 let mut ws = connect().await;
281 let keys = Keys::generate();
282
283 // Send an announcement
284 let event = create_announcement(
285 &keys,
286 "query-test-repo",
287 vec![&format!("https://{}/alice/query-test-repo.git", DOMAIN)],
288 vec![&format!("wss://{}", DOMAIN)],
289 );
290
291 send_event(&mut ws, event.clone()).await;
292
293 // Query for announcements
294 let req = json!([
295 "REQ",
296 "test-sub",
297 {
298 "kinds": [KIND_REPOSITORY_ANNOUNCEMENT],
299 "authors": [keys.public_key().to_hex()]
300 }
301 ]);
302
303 ws.send(Message::Text(req.to_string()))
304 .await
305 .expect("Failed to send REQ");
306
307 // Read responses
308 let mut found_event = false;
309 let mut got_eose = false;
310
311 for _ in 0..10 {
312 if let Some(Ok(Message::Text(text))) = ws.next().await {
313 let response: Value = serde_json::from_str(&text).expect("Failed to parse");
314
315 if response[0] == "EVENT" {
316 assert_eq!(response[1], "test-sub");
317 found_event = true;
318 } else if response[0] == "EOSE" {
319 assert_eq!(response[1], "test-sub");
320 got_eose = true;
321 break;
322 }
323 }
324 }
325
326 assert!(found_event, "Should have received the announcement");
327 assert!(got_eose, "Should have received EOSE");
328}
329
330/// Test querying for state events
331#[tokio::test]
332#[ignore] // Requires relay to be running
333async fn test_query_states() {
334 let mut ws = connect().await;
335 let keys = Keys::generate();
336
337 // Send a state event
338 let event = create_state(
339 &keys,
340 "query-test-repo",
341 vec![("main", "a1b2c3d4e5f6789012345678901234567890abcd")],
342 );
343
344 send_event(&mut ws, event.clone()).await;
345
346 // Query for states
347 let req = json!([
348 "REQ",
349 "test-sub",
350 {
351 "kinds": [KIND_REPOSITORY_STATE],
352 "authors": [keys.public_key().to_hex()]
353 }
354 ]);
355
356 ws.send(Message::Text(req.to_string()))
357 .await
358 .expect("Failed to send REQ");
359
360 // Read responses
361 let mut found_event = false;
362 let mut got_eose = false;
363
364 for _ in 0..10 {
365 if let Some(Ok(Message::Text(text))) = ws.next().await {
366 let response: Value = serde_json::from_str(&text).expect("Failed to parse");
367
368 if response[0] == "EVENT" {
369 assert_eq!(response[1], "test-sub");
370 found_event = true;
371 } else if response[0] == "EOSE" {
372 assert_eq!(response[1], "test-sub");
373 got_eose = true;
374 break;
375 }
376 }
377 }
378
379 assert!(found_event, "Should have received the state event");
380 assert!(got_eose, "Should have received EOSE");
381}
382
383/// Test duplicate event handling
384#[tokio::test]
385#[ignore] // Requires relay to be running
386async fn test_duplicate_announcement() {
387 let mut ws = connect().await;
388 let keys = Keys::generate();
389
390 let event = create_announcement(
391 &keys,
392 "duplicate-test",
393 vec![&format!("https://{}/alice/duplicate-test.git", DOMAIN)],
394 vec![&format!("wss://{}", DOMAIN)],
395 );
396
397 // Send first time
398 let response1 = send_event(&mut ws, event.clone()).await;
399 assert_eq!(response1[2], true, "First send should succeed");
400
401 // Send second time (duplicate)
402 let response2 = send_event(&mut ws, event.clone()).await;
403 assert_eq!(response2[2], true, "Duplicate should be acknowledged");
404
405 let message = response2[3].as_str().unwrap();
406 assert!(
407 message.contains("duplicate") || message.is_empty(),
408 "Should indicate duplicate: {}",
409 message
410 );
411}
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 @@
1//! Common test utilities
2
3pub mod relay;
4
5pub 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 @@
1//! Test relay fixture
2//!
3//! Provides automatic relay lifecycle management for integration tests.
4
5use std::process::{Child, Command, Stdio};
6use std::time::Duration;
7use tokio::time::sleep;
8
9/// Test relay fixture that manages relay lifecycle
10///
11/// Automatically starts and stops the ngit-grasp relay for testing.
12/// Uses a random port to avoid conflicts.
13pub struct TestRelay {
14 process: Child,
15 url: String,
16 port: u16,
17}
18
19impl TestRelay {
20 /// Start a test relay instance
21 ///
22 /// # Example
23 ///
24 /// ```no_run
25 /// use common::TestRelay;
26 ///
27 /// #[tokio::test]
28 /// async fn test_something() {
29 /// let relay = TestRelay::start().await;
30 /// // Use relay.url() for testing
31 /// relay.stop().await;
32 /// }
33 /// ```
34 pub async fn start() -> Self {
35 Self::start_with_port(Self::find_free_port()).await
36 }
37
38 /// Start relay on a specific port
39 pub async fn start_with_port(port: u16) -> Self {
40 let bind_address = format!("127.0.0.1:{}", port);
41 let url = format!("ws://127.0.0.1:{}", port);
42
43 // Use the built binary directly (faster than cargo run)
44 let binary_path = std::env::current_exe()
45 .expect("Failed to get current exe")
46 .parent()
47 .expect("Failed to get parent dir")
48 .parent()
49 .expect("Failed to get grandparent dir")
50 .join("ngit-grasp");
51
52 // Start the relay process
53 let process = Command::new(&binary_path)
54 .env("NGIT_BIND_ADDRESS", &bind_address)
55 .env("NGIT_DOMAIN", &bind_address) // Set domain to match bind address
56 .env("RUST_LOG", "warn") // Less logging during tests
57 .stdout(Stdio::null())
58 .stderr(Stdio::null())
59 .spawn()
60 .expect("Failed to start relay process");
61
62 let relay = Self {
63 process,
64 url,
65 port,
66 };
67
68 // Wait for relay to be ready
69 relay.wait_for_ready().await;
70
71 relay
72 }
73
74 /// Get the relay WebSocket URL
75 pub fn url(&self) -> &str {
76 &self.url
77 }
78
79 /// Get the relay port
80 pub fn port(&self) -> u16 {
81 self.port
82 }
83
84 /// Get the relay domain (host:port)
85 pub fn domain(&self) -> String {
86 format!("127.0.0.1:{}", self.port)
87 }
88
89 /// Wait for the relay to be ready to accept connections
90 async fn wait_for_ready(&self) {
91 let max_attempts = 50; // 5 seconds total
92 let delay = Duration::from_millis(100);
93
94 for attempt in 0..max_attempts {
95 // Try to connect to the relay
96 match tokio::net::TcpStream::connect(format!("127.0.0.1:{}", self.port)).await {
97 Ok(_) => {
98 // Connection successful, relay is ready
99 // Give it a tiny bit more time to fully initialize
100 sleep(Duration::from_millis(100)).await;
101 return;
102 }
103 Err(_) => {
104 if attempt == max_attempts - 1 {
105 panic!("Relay failed to start after {} attempts", max_attempts);
106 }
107 sleep(delay).await;
108 }
109 }
110 }
111 }
112
113 /// Stop the relay
114 pub async fn stop(mut self) {
115 // Send SIGTERM to gracefully shutdown
116 #[cfg(unix)]
117 {
118 use nix::sys::signal::{kill, Signal};
119 use nix::unistd::Pid;
120
121 let pid = Pid::from_raw(self.process.id() as i32);
122 let _ = kill(pid, Signal::SIGTERM);
123 }
124
125 // Wait a bit for graceful shutdown
126 sleep(Duration::from_millis(100)).await;
127
128 // Force kill if still running
129 let _ = self.process.kill();
130 let _ = self.process.wait();
131 }
132
133 /// Find a free port to use for testing
134 fn find_free_port() -> u16 {
135 use std::net::TcpListener;
136
137 // Bind to port 0 to get a random free port
138 let listener = TcpListener::bind("127.0.0.1:0")
139 .expect("Failed to bind to random port");
140
141 let port = listener.local_addr()
142 .expect("Failed to get local address")
143 .port();
144
145 // Drop the listener to free the port
146 drop(listener);
147
148 port
149 }
150}
151
152impl Drop for TestRelay {
153 fn drop(&mut self) {
154 // Ensure process is killed when TestRelay is dropped
155 let _ = self.process.kill();
156 let _ = self.process.wait();
157 }
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163
164 #[tokio::test]
165 #[ignore] // Requires relay binary to be built
166 async fn test_relay_lifecycle() {
167 let relay = TestRelay::start().await;
168 assert!(relay.url().starts_with("ws://127.0.0.1:"));
169 assert!(relay.port() > 0);
170 relay.stop().await;
171 }
172
173 #[test]
174 fn test_find_free_port() {
175 let port = TestRelay::find_free_port();
176 assert!(port > 0);
177 // Port is u16, so it's always < 65536
178 }
179}
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 @@
1//! NIP-01 Compliance Integration Tests
2//!
3//! These tests verify that ngit-grasp relay implements NIP-01 correctly
4//! by using the grasp-audit library to run compliance tests.
5//!
6//! # Test Strategy
7//!
8//! - Uses grasp-audit as a library (not CLI)
9//! - Automatically manages relay lifecycle
10//! - Reuses test specs from grasp-audit (single source of truth)
11//! - Pure Rust, no shell scripts
12//!
13//! # Running Tests
14//!
15//! ```bash
16//! # Run all NIP-01 compliance tests
17//! cargo test --test nip01_compliance
18//!
19//! # Run specific test
20//! cargo test --test nip01_compliance test_nip01_smoke
21//!
22//! # With output
23//! cargo test --test nip01_compliance -- --nocapture
24//! ```
25
26mod common;
27
28use common::TestRelay;
29use grasp_audit::*;
30
31/// Test NIP-01 smoke tests against ngit-grasp relay
32///
33/// This test:
34/// 1. Starts a fresh ngit-grasp relay instance
35/// 2. Runs all NIP-01 smoke tests from grasp-audit
36/// 3. Verifies all tests pass
37/// 4. Shuts down the relay
38#[tokio::test]
39async fn test_nip01_smoke() {
40 // Start test relay
41 let relay = TestRelay::start().await;
42
43 // Create audit client in CI mode (isolated, no cleanup needed)
44 let config = AuditConfig::ci();
45 let client = AuditClient::new(relay.url(), config)
46 .await
47 .expect("Failed to create audit client");
48
49 // Run all NIP-01 smoke tests
50 let results = specs::Nip01SmokeTests::run_all(&client).await;
51
52 // Print detailed report
53 results.print_report();
54
55 // Stop relay
56 relay.stop().await;
57
58 // Assert all tests passed
59 assert!(
60 results.all_passed(),
61 "NIP-01 smoke tests failed: {}/{} passed",
62 results.passed_count(),
63 results.total_count()
64 );
65}
66
67/// Test individual NIP-01 tests can be run separately
68///
69/// This demonstrates that we can run individual tests from the specs
70/// for more granular testing or debugging.
71#[tokio::test]
72async fn test_nip01_individual_tests() {
73 use grasp_audit::specs::nip01_smoke::Nip01SmokeTests;
74
75 let relay = TestRelay::start().await;
76 let config = AuditConfig::ci();
77 let client = AuditClient::new(relay.url(), config)
78 .await
79 .expect("Failed to create audit client");
80
81 // We can't call private methods, so we'll run the full suite
82 // This test is mainly to show the pattern
83 let all_results = Nip01SmokeTests::run_all(&client).await;
84
85 relay.stop().await;
86
87 // Verify
88 assert!(all_results.all_passed());
89}
90
91/// Test that relay rejects invalid events
92///
93/// This is a critical security test - we want to ensure the relay
94/// properly validates events before accepting them.
95#[tokio::test]
96async fn test_relay_validates_events() {
97 let relay = TestRelay::start().await;
98 let config = AuditConfig::ci();
99 let client = AuditClient::new(relay.url(), config)
100 .await
101 .expect("Failed to create audit client");
102
103 // The validation tests are part of the smoke tests
104 let results = specs::Nip01SmokeTests::run_all(&client).await;
105
106 // Check that validation tests exist and pass
107 let validation_tests: Vec<_> = results
108 .results
109 .iter()
110 .filter(|t| t.spec_ref.contains("validation"))
111 .collect();
112
113 relay.stop().await;
114
115 // Should have validation tests
116 assert!(
117 !validation_tests.is_empty(),
118 "No validation tests found in NIP-01 smoke tests"
119 );
120
121 // All validation tests should pass
122 for test in validation_tests {
123 assert!(
124 test.passed,
125 "Validation test failed: {} - {}",
126 test.name,
127 test.error.as_deref().unwrap_or("unknown error")
128 );
129 }
130}
131
132/// Test relay lifecycle management
133///
134/// Ensures our test fixture properly manages relay lifecycle
135#[tokio::test]
136async fn test_relay_lifecycle() {
137 // Start relay
138 let relay = TestRelay::start().await;
139 let url = relay.url().to_string();
140
141 // Verify we can connect
142 let config = AuditConfig::ci();
143 let client = AuditClient::new(&url, config)
144 .await
145 .expect("Failed to connect to relay");
146
147 assert!(client.is_connected().await, "Client should be connected");
148
149 // Stop relay
150 relay.stop().await;
151
152 // Note: We can't easily verify disconnection without modifying grasp-audit
153 // to expose connection state after relay shutdown. That's okay - the
154 // important part is that the relay starts and stops cleanly.
155}
156
157/// Test multiple relays can run in parallel
158///
159/// This ensures our random port selection works correctly
160#[tokio::test]
161async fn test_parallel_relays() {
162 // Start two relays simultaneously
163 let relay1 = TestRelay::start().await;
164 let relay2 = TestRelay::start().await;
165
166 // Should have different URLs (different ports)
167 assert_ne!(
168 relay1.url(),
169 relay2.url(),
170 "Relays should use different ports"
171 );
172
173 // Both should be connectable
174 let config = AuditConfig::ci();
175
176 let client1 = AuditClient::new(relay1.url(), config.clone())
177 .await
178 .expect("Failed to connect to relay 1");
179
180 let client2 = AuditClient::new(relay2.url(), config)
181 .await
182 .expect("Failed to connect to relay 2");
183
184 assert!(client1.is_connected().await);
185 assert!(client2.is_connected().await);
186
187 // Clean up
188 relay1.stop().await;
189 relay2.stop().await;
190}
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 @@
1//! NIP-34 Repository Announcements Integration Tests (GRASP-01)
2//!
3//! Tests the acceptance and validation of repository announcements (kind 30617)
4//! and repository state announcements (kind 30618) according to GRASP-01.
5//!
6//! Reference: GRASP-01, Lines 9-20
7//!
8//! # Test Strategy
9//!
10//! - Uses TestRelay fixture for automatic relay lifecycle management
11//! - Pure Rust, no shell scripts
12//! - Tests run in parallel with isolated relay instances
13//!
14//! # Running Tests
15//!
16//! ```bash
17//! # Run all NIP-34 announcement tests
18//! cargo test --test nip34_announcements
19//!
20//! # Run specific test
21//! cargo test --test nip34_announcements test_accepts_valid_announcement
22//!
23//! # With output
24//! cargo test --test nip34_announcements -- --nocapture
25//! ```
26
27mod common;
28
29use common::TestRelay;
30use futures_util::{SinkExt, StreamExt};
31use nostr_sdk::{EventBuilder, Keys, Kind, Tag, TagKind};
32use serde_json::{json, Value};
33use tokio_tungstenite::{connect_async, tungstenite::Message};
34
35const KIND_REPOSITORY_ANNOUNCEMENT: u16 = 30617;
36const KIND_REPOSITORY_STATE: u16 = 30618;
37
38/// Helper to connect to a test relay
39async fn connect_to_relay(url: &str) -> tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>> {
40 let (ws, _) = connect_async(url)
41 .await
42 .expect("Failed to connect to relay");
43 ws
44}
45
46/// Helper to create a repository announcement event
47fn create_announcement(
48 keys: &Keys,
49 _domain: &str,
50 identifier: &str,
51 clone_urls: Vec<String>,
52 relays: Vec<String>,
53) -> nostr_sdk::Event {
54 let mut tags = vec![Tag::custom(TagKind::d(), vec![identifier.to_string()])];
55
56 for url in clone_urls {
57 tags.push(Tag::custom(
58 TagKind::Clone,
59 vec![url],
60 ));
61 }
62
63 for relay in relays {
64 tags.push(Tag::custom(TagKind::Relays, vec![relay]));
65 }
66
67 EventBuilder::new(
68 Kind::from(KIND_REPOSITORY_ANNOUNCEMENT),
69 "Test repository description",
70 )
71 .tags(tags)
72 .sign_with_keys(keys)
73 .expect("Failed to sign event")
74}
75
76/// Helper to create a repository state event
77fn create_state(keys: &Keys, identifier: &str, branches: Vec<(&str, &str)>) -> nostr_sdk::Event {
78 let mut tags = vec![Tag::custom(TagKind::d(), vec![identifier.to_string()])];
79
80 for (branch, commit) in branches {
81 tags.push(Tag::custom(
82 TagKind::Custom("ref".into()),
83 vec![format!("refs/heads/{}", branch), commit.to_string()],
84 ));
85 }
86
87 EventBuilder::new(Kind::from(KIND_REPOSITORY_STATE), "")
88 .tags(tags)
89 .sign_with_keys(keys)
90 .expect("Failed to sign event")
91}
92
93/// GRASP-01, Line 9-10: MUST serve a NIP-01 compliant nostr relay at `/`
94#[tokio::test]
95async fn test_relay_accepts_connection() {
96 let relay = TestRelay::start().await;
97
98 // Try to connect
99 let ws = connect_to_relay(relay.url()).await;
100
101 drop(ws); // Clean disconnect
102}
103
104/// GRASP-01, Line 11: MUST accept repository announcements (kind 30617)
105#[tokio::test]
106async fn test_accepts_valid_announcement() {
107 let relay = TestRelay::start().await;
108 let keys = Keys::generate();
109
110 let mut ws = connect_to_relay(relay.url()).await;
111
112 let event = create_announcement(
113 &keys,
114 &relay.domain(),
115 "test-repo",
116 vec![format!("https://{}/alice/test-repo.git", relay.domain())],
117 vec![format!("wss://{}", relay.domain())],
118 );
119
120 // Send event
121 let event_msg = json!(["EVENT", event]);
122 ws.send(Message::Text(event_msg.to_string()))
123 .await
124 .expect("Failed to send event");
125
126 // Read response
127 if let Some(Ok(Message::Text(text))) = ws.next().await {
128 let response: Value = serde_json::from_str(&text).expect("Failed to parse response");
129
130 // Should be ["OK", event_id, true, ""]
131 assert_eq!(response[0], "OK");
132 assert_eq!(response[1], event.id.to_hex());
133 if response[2] != true {
134 eprintln!("Event rejected: {}", response[3]);
135 }
136 assert_eq!(response[2], true, "Event should be accepted");
137 } else {
138 panic!("No response received");
139 }
140}
141
142/// GRASP-01, Line 12-13: MUST reject announcements that do not list the service
143/// in both `clone` and `relays` tags
144#[tokio::test]
145async fn test_rejects_announcement_without_clone() {
146 let relay = TestRelay::start().await;
147 let keys = Keys::generate();
148
149 let (mut ws, _) = connect_async(relay.url())
150 .await
151 .expect("Failed to connect");
152
153 // Missing clone tag
154 let event = create_announcement(
155 &keys,
156 &relay.domain(),
157 "test-repo",
158 vec![], // No clone URLs
159 vec![format!("wss://{}", relay.domain())],
160 );
161
162 let event_msg = json!(["EVENT", event]);
163 ws.send(Message::Text(event_msg.to_string()))
164 .await
165 .expect("Failed to send event");
166
167 if let Some(Ok(Message::Text(text))) = ws.next().await {
168 let response: Value = serde_json::from_str(&text).expect("Failed to parse");
169
170 // Should be rejected
171 assert_eq!(response[0], "OK");
172 assert_eq!(response[1], event.id.to_hex());
173 assert_eq!(response[2], false, "Event should be rejected");
174
175 let message = response[3].as_str().unwrap();
176 assert!(
177 message.contains("clone") || message.contains("invalid"),
178 "Error message should mention clone requirement: {}",
179 message
180 );
181 } else {
182 panic!("No response received");
183 }
184}
185
186/// GRASP-01, Line 12-13: MUST reject announcements that do not list the service
187/// in both `clone` and `relays` tags
188#[tokio::test]
189async fn test_rejects_announcement_without_relay() {
190 let relay = TestRelay::start().await;
191 let keys = Keys::generate();
192
193 let (mut ws, _) = connect_async(relay.url())
194 .await
195 .expect("Failed to connect");
196
197 // Missing relay tag
198 let event = create_announcement(
199 &keys,
200 &relay.domain(),
201 "test-repo",
202 vec![format!("https://{}/alice/test-repo.git", relay.domain())],
203 vec![], // No relays
204 );
205
206 let event_msg = json!(["EVENT", event]);
207 ws.send(Message::Text(event_msg.to_string()))
208 .await
209 .expect("Failed to send event");
210
211 if let Some(Ok(Message::Text(text))) = ws.next().await {
212 let response: Value = serde_json::from_str(&text).expect("Failed to parse");
213
214 // Should be rejected
215 assert_eq!(response[0], "OK");
216 assert_eq!(response[1], event.id.to_hex());
217 assert_eq!(response[2], false, "Event should be rejected");
218
219 let message = response[3].as_str().unwrap();
220 assert!(
221 message.contains("relays") || message.contains("invalid"),
222 "Error message should mention relay requirement: {}",
223 message
224 );
225 } else {
226 panic!("No response received");
227 }
228}
229
230/// GRASP-01, Line 12-13: MUST reject announcements listing other services
231#[tokio::test]
232async fn test_rejects_announcement_for_other_service() {
233 let relay = TestRelay::start().await;
234 let keys = Keys::generate();
235
236 let (mut ws, _) = connect_async(relay.url())
237 .await
238 .expect("Failed to connect");
239
240 // Lists different service
241 let event = create_announcement(
242 &keys,
243 &relay.domain(),
244 "test-repo",
245 vec!["https://other-service.com/alice/test-repo.git".to_string()],
246 vec!["wss://other-service.com".to_string()],
247 );
248
249 let event_msg = json!(["EVENT", event]);
250 ws.send(Message::Text(event_msg.to_string()))
251 .await
252 .expect("Failed to send event");
253
254 if let Some(Ok(Message::Text(text))) = ws.next().await {
255 let response: Value = serde_json::from_str(&text).expect("Failed to parse");
256
257 // Should be rejected
258 assert_eq!(response[0], "OK");
259 assert_eq!(response[1], event.id.to_hex());
260 assert_eq!(response[2], false, "Event should be rejected");
261 } else {
262 panic!("No response received");
263 }
264}
265
266/// GRASP-01, Line 11: MUST accept repository state announcements (kind 30618)
267#[tokio::test]
268async fn test_accepts_valid_state() {
269 let relay = TestRelay::start().await;
270 let keys = Keys::generate();
271
272 let (mut ws, _) = connect_async(relay.url())
273 .await
274 .expect("Failed to connect");
275
276 let event = create_state(
277 &keys,
278 "test-repo",
279 vec![("main", "a1b2c3d4e5f6789012345678901234567890abcd")],
280 );
281
282 let event_msg = json!(["EVENT", event]);
283 ws.send(Message::Text(event_msg.to_string()))
284 .await
285 .expect("Failed to send event");
286
287 if let Some(Ok(Message::Text(text))) = ws.next().await {
288 let response: Value = serde_json::from_str(&text).expect("Failed to parse");
289
290 // Should be accepted
291 assert_eq!(response[0], "OK");
292 assert_eq!(response[1], event.id.to_hex());
293 assert_eq!(response[2], true, "State event should be accepted");
294 } else {
295 panic!("No response received");
296 }
297}
298
299/// Test state event with multiple branches
300#[tokio::test]
301async fn test_accepts_state_with_multiple_branches() {
302 let relay = TestRelay::start().await;
303 let keys = Keys::generate();
304
305 let (mut ws, _) = connect_async(relay.url())
306 .await
307 .expect("Failed to connect");
308
309 let event = create_state(
310 &keys,
311 "test-repo",
312 vec![
313 ("main", "a1b2c3d4e5f6789012345678901234567890abcd"),
314 ("develop", "b2c3d4e5f6789012345678901234567890abcde"),
315 ("feature-x", "c3d4e5f6789012345678901234567890abcdef1"),
316 ],
317 );
318
319 let event_msg = json!(["EVENT", event]);
320 ws.send(Message::Text(event_msg.to_string()))
321 .await
322 .expect("Failed to send event");
323
324 if let Some(Ok(Message::Text(text))) = ws.next().await {
325 let response: Value = serde_json::from_str(&text).expect("Failed to parse");
326
327 assert_eq!(response[0], "OK");
328 assert_eq!(response[2], true, "State event should be accepted");
329 } else {
330 panic!("No response received");
331 }
332}
333
334/// Test state event without identifier should be rejected
335#[tokio::test]
336async fn test_rejects_state_without_identifier() {
337 let relay = TestRelay::start().await;
338 let keys = Keys::generate();
339
340 let (mut ws, _) = connect_async(relay.url())
341 .await
342 .expect("Failed to connect");
343
344 // Create state without identifier
345 let event = EventBuilder::new(Kind::from(KIND_REPOSITORY_STATE), "")
346 .sign_with_keys(&keys)
347 .expect("Failed to sign event");
348
349 let event_msg = json!(["EVENT", event]);
350 ws.send(Message::Text(event_msg.to_string()))
351 .await
352 .expect("Failed to send event");
353
354 if let Some(Ok(Message::Text(text))) = ws.next().await {
355 let response: Value = serde_json::from_str(&text).expect("Failed to parse");
356
357 // Should be rejected
358 assert_eq!(response[0], "OK");
359 assert_eq!(response[1], event.id.to_hex());
360 assert_eq!(response[2], false, "Event should be rejected");
361
362 let message = response[3].as_str().unwrap();
363 assert!(
364 message.contains("identifier") || message.contains("invalid"),
365 "Error message should mention identifier requirement: {}",
366 message
367 );
368 } else {
369 panic!("No response received");
370 }
371}
372
373/// Test querying for announcements
374#[tokio::test]
375async fn test_query_announcements() {
376 let relay = TestRelay::start().await;
377 let keys = Keys::generate();
378
379 let (mut ws, _) = connect_async(relay.url())
380 .await
381 .expect("Failed to connect");
382
383 // Send an announcement
384 let event = create_announcement(
385 &keys,
386 &relay.domain(),
387 "query-test-repo",
388 vec![format!("https://{}/alice/query-test-repo.git", relay.domain())],
389 vec![format!("wss://{}", relay.domain())],
390 );
391
392 let event_msg = json!(["EVENT", event]);
393 ws.send(Message::Text(event_msg.to_string()))
394 .await
395 .expect("Failed to send event");
396
397 // Wait for OK response
398 if let Some(Ok(Message::Text(_))) = ws.next().await {
399 // Got OK response
400 }
401
402 // Query for announcements
403 let req = json!([
404 "REQ",
405 "test-sub",
406 {
407 "kinds": [KIND_REPOSITORY_ANNOUNCEMENT],
408 "authors": [keys.public_key().to_hex()]
409 }
410 ]);
411
412 ws.send(Message::Text(req.to_string()))
413 .await
414 .expect("Failed to send REQ");
415
416 // Read responses
417 let mut found_event = false;
418 let mut got_eose = false;
419
420 for _ in 0..10 {
421 if let Some(Ok(Message::Text(text))) = ws.next().await {
422 let response: Value = serde_json::from_str(&text).expect("Failed to parse");
423
424 if response[0] == "EVENT" {
425 assert_eq!(response[1], "test-sub");
426 found_event = true;
427 } else if response[0] == "EOSE" {
428 assert_eq!(response[1], "test-sub");
429 got_eose = true;
430 break;
431 }
432 }
433 }
434
435 assert!(found_event, "Should have received the announcement");
436 assert!(got_eose, "Should have received EOSE");
437}
438
439/// Test querying for state events
440#[tokio::test]
441async fn test_query_states() {
442 let relay = TestRelay::start().await;
443 let keys = Keys::generate();
444
445 let (mut ws, _) = connect_async(relay.url())
446 .await
447 .expect("Failed to connect");
448
449 // Send a state event
450 let event = create_state(
451 &keys,
452 "query-test-repo",
453 vec![("main", "a1b2c3d4e5f6789012345678901234567890abcd")],
454 );
455
456 let event_msg = json!(["EVENT", event]);
457 ws.send(Message::Text(event_msg.to_string()))
458 .await
459 .expect("Failed to send event");
460
461 // Wait for OK response
462 if let Some(Ok(Message::Text(_))) = ws.next().await {
463 // Got OK response
464 }
465
466 // Query for states
467 let req = json!([
468 "REQ",
469 "test-sub",
470 {
471 "kinds": [KIND_REPOSITORY_STATE],
472 "authors": [keys.public_key().to_hex()]
473 }
474 ]);
475
476 ws.send(Message::Text(req.to_string()))
477 .await
478 .expect("Failed to send REQ");
479
480 // Read responses
481 let mut found_event = false;
482 let mut got_eose = false;
483
484 for _ in 0..10 {
485 if let Some(Ok(Message::Text(text))) = ws.next().await {
486 let response: Value = serde_json::from_str(&text).expect("Failed to parse");
487
488 if response[0] == "EVENT" {
489 assert_eq!(response[1], "test-sub");
490 found_event = true;
491 } else if response[0] == "EOSE" {
492 assert_eq!(response[1], "test-sub");
493 got_eose = true;
494 break;
495 }
496 }
497 }
498
499 assert!(found_event, "Should have received the state event");
500 assert!(got_eose, "Should have received EOSE");
501}
502
503/// Test duplicate event handling
504#[tokio::test]
505async fn test_duplicate_announcement() {
506 let relay = TestRelay::start().await;
507 let keys = Keys::generate();
508
509 let (mut ws, _) = connect_async(relay.url())
510 .await
511 .expect("Failed to connect");
512
513 let event = create_announcement(
514 &keys,
515 &relay.domain(),
516 "duplicate-test",
517 vec![format!("https://{}/alice/duplicate-test.git", relay.domain())],
518 vec![format!("wss://{}", relay.domain())],
519 );
520
521 // Send first time
522 let event_msg = json!(["EVENT", event]);
523 ws.send(Message::Text(event_msg.to_string()))
524 .await
525 .expect("Failed to send event");
526
527 if let Some(Ok(Message::Text(text))) = ws.next().await {
528 let response1: Value = serde_json::from_str(&text).expect("Failed to parse");
529 assert_eq!(response1[2], true, "First send should succeed");
530 }
531
532 // Send second time (duplicate)
533 let event_msg = json!(["EVENT", event]);
534 ws.send(Message::Text(event_msg.to_string()))
535 .await
536 .expect("Failed to send event");
537
538 if let Some(Ok(Message::Text(text))) = ws.next().await {
539 let response2: Value = serde_json::from_str(&text).expect("Failed to parse");
540 assert_eq!(response2[2], true, "Duplicate should be acknowledged");
541
542 let message = response2[3].as_str().unwrap();
543 assert!(
544 message.contains("duplicate") || message.is_empty(),
545 "Should indicate duplicate: {}",
546 message
547 );
548 }
549}