upleb.uk

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

summaryrefslogtreecommitdiff
path: root/tests/common
diff options
context:
space:
mode:
Diffstat (limited to 'tests/common')
-rw-r--r--tests/common/mock_relay.rs336
-rw-r--r--tests/common/mod.rs2
-rw-r--r--tests/common/purgatory_helpers.rs115
3 files changed, 453 insertions, 0 deletions
diff --git a/tests/common/mock_relay.rs b/tests/common/mock_relay.rs
new file mode 100644
index 0000000..123c29e
--- /dev/null
+++ b/tests/common/mock_relay.rs
@@ -0,0 +1,336 @@
1//! Mock Nostr Relay for Testing
2//!
3//! Provides a simple Nostr relay that accepts all events without validation.
4//! Uses rust-nostr's `LocalRelayBuilder` to create an in-memory relay.
5//!
6//! # Usage
7//!
8//! ```ignore
9//! use common::MockRelay;
10//!
11//! #[tokio::test]
12//! async fn test_mock_relay() {
13//! // Start the mock relay
14//! let mock = MockRelay::start().await;
15//!
16//! // Use mock.url() for WebSocket connections
17//! let client = Client::new(keys);
18//! client.add_relay(mock.url()).await.unwrap();
19//!
20//! // All events are accepted without validation
21//! client.send_event(&event).await.unwrap();
22//!
23//! // Cleanup
24//! mock.stop().await;
25//! }
26//! ```
27//!
28//! # How It Works
29//!
30//! The mock relay:
31//! - Uses `LocalRelayBuilder::default().build()` which accepts all events
32//! - Runs an HTTP server with WebSocket upgrade support
33//! - Stores events in an in-memory database
34//! - Does NOT perform any GRASP validation (no purgatory, no git data checks)
35
36use std::net::SocketAddr;
37use std::sync::Arc;
38
39use http_body_util::Full;
40use hyper::body::Bytes;
41use hyper::header::{CONNECTION, SEC_WEBSOCKET_ACCEPT, SEC_WEBSOCKET_KEY, UPGRADE};
42use hyper::server::conn::http1;
43use hyper::service::service_fn;
44use hyper::{Request, Response, StatusCode};
45use hyper_util::rt::TokioIo;
46use nostr_relay_builder::prelude::*;
47use tokio::net::TcpListener;
48use tokio::sync::oneshot;
49
50/// Mock Nostr relay that accepts all events without validation.
51///
52/// This relay is useful for testing scenarios where you need a relay
53/// that serves events without GRASP validation (no purgatory, no git checks).
54pub struct MockRelay {
55 /// Shutdown signal sender
56 shutdown_tx: Option<oneshot::Sender<()>>,
57 /// Server task handle
58 handle: Option<tokio::task::JoinHandle<()>>,
59 /// Server URL (ws://127.0.0.1:<port>)
60 url: String,
61 /// Server port
62 #[allow(dead_code)]
63 port: u16,
64 /// The underlying LocalRelay (kept alive for the server lifetime)
65 #[allow(dead_code)]
66 relay: LocalRelay,
67}
68
69impl MockRelay {
70 /// Start a mock relay on a random free port.
71 ///
72 /// The relay accepts all events without validation and stores them
73 /// in an in-memory database.
74 pub async fn start() -> Self {
75 let port = find_free_port();
76 Self::start_on_port(port).await
77 }
78
79 /// Start a mock relay on a specific port.
80 pub async fn start_on_port(port: u16) -> Self {
81 let addr: SocketAddr = ([127, 0, 0, 1], port).into();
82
83 // Create a simple relay with no write policy (accepts all events)
84 let relay = LocalRelayBuilder::default().build();
85
86 // Create shutdown channel
87 let (shutdown_tx, mut shutdown_rx) = oneshot::channel::<()>();
88
89 // Clone relay for the server task
90 let server_relay = relay.clone();
91
92 // Start the HTTP/WebSocket server
93 let listener = TcpListener::bind(addr)
94 .await
95 .expect("Failed to bind to address");
96
97 let handle = tokio::spawn(async move {
98 loop {
99 tokio::select! {
100 accept_result = listener.accept() => {
101 match accept_result {
102 Ok((stream, remote_addr)) => {
103 let relay = server_relay.clone();
104 let io = TokioIo::new(stream);
105
106 tokio::spawn(async move {
107 let service = service_fn(move |req| {
108 let relay = relay.clone();
109 async move { handle_request(req, relay, remote_addr).await }
110 });
111
112 if let Err(e) = http1::Builder::new()
113 .serve_connection(io, service)
114 .with_upgrades()
115 .await
116 {
117 // Connection errors are expected when client disconnects
118 if !e.to_string().contains("connection") {
119 eprintln!("MockRelay connection error: {}", e);
120 }
121 }
122 });
123 }
124 Err(e) => {
125 eprintln!("MockRelay accept error: {}", e);
126 }
127 }
128 }
129 _ = &mut shutdown_rx => {
130 // Shutdown signal received
131 break;
132 }
133 }
134 }
135 });
136
137 let url = format!("ws://127.0.0.1:{}", port);
138
139 // Wait for server to be ready
140 wait_for_server_ready(port).await;
141
142 Self {
143 shutdown_tx: Some(shutdown_tx),
144 handle: Some(handle),
145 url,
146 port,
147 relay,
148 }
149 }
150
151 /// Get the relay WebSocket URL.
152 pub fn url(&self) -> &str {
153 &self.url
154 }
155
156 /// Stop the mock relay.
157 pub async fn stop(mut self) {
158 // Send shutdown signal
159 if let Some(tx) = self.shutdown_tx.take() {
160 let _ = tx.send(());
161 }
162
163 // Wait for server task to complete
164 if let Some(handle) = self.handle.take() {
165 let _ = handle.await;
166 }
167 }
168}
169
170impl Drop for MockRelay {
171 fn drop(&mut self) {
172 // Send shutdown signal if not already sent
173 if let Some(tx) = self.shutdown_tx.take() {
174 let _ = tx.send(());
175 }
176 }
177}
178
179/// Handle an HTTP request, upgrading to WebSocket if requested.
180async fn handle_request(
181 req: Request<hyper::body::Incoming>,
182 relay: LocalRelay,
183 addr: SocketAddr,
184) -> Result<Response<Full<Bytes>>, hyper::Error> {
185 // Check for WebSocket upgrade request
186 let is_websocket = req
187 .headers()
188 .get(UPGRADE)
189 .map(|v| v.to_str().unwrap_or("").to_lowercase() == "websocket")
190 .unwrap_or(false);
191
192 if is_websocket {
193 // Get the Sec-WebSocket-Key header
194 let key = req
195 .headers()
196 .get(SEC_WEBSOCKET_KEY)
197 .and_then(|k| k.to_str().ok())
198 .map(|k| k.to_string());
199
200 if let Some(key) = key {
201 let accept_key = derive_accept_key(key.as_bytes());
202
203 // Spawn task to handle the upgraded connection
204 tokio::spawn(async move {
205 match hyper::upgrade::on(req).await {
206 Ok(upgraded) => {
207 if let Err(e) = relay.take_connection(TokioIo::new(upgraded), addr).await {
208 eprintln!("MockRelay WebSocket error: {}", e);
209 }
210 }
211 Err(e) => eprintln!("MockRelay upgrade error: {}", e),
212 }
213 });
214
215 // Return 101 Switching Protocols
216 return Ok(Response::builder()
217 .status(StatusCode::SWITCHING_PROTOCOLS)
218 .header(CONNECTION, "upgrade")
219 .header(UPGRADE, "websocket")
220 .header(SEC_WEBSOCKET_ACCEPT, accept_key)
221 .body(Full::new(Bytes::new()))
222 .unwrap());
223 }
224 }
225
226 // Non-WebSocket request - return simple response
227 Ok(Response::builder()
228 .status(StatusCode::OK)
229 .header("Content-Type", "text/plain")
230 .body(Full::new(Bytes::from("MockRelay - Nostr test relay")))
231 .unwrap())
232}
233
234/// Derive the Sec-WebSocket-Accept key from the request key.
235fn derive_accept_key(request_key: &[u8]) -> String {
236 use nostr_sdk::hashes::sha1::Hash as Sha1Hash;
237 use nostr_sdk::hashes::{Hash, HashEngine};
238
239 const WS_GUID: &[u8] = b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
240
241 let mut engine = Sha1Hash::engine();
242 engine.input(request_key);
243 engine.input(WS_GUID);
244 let hash = Sha1Hash::from_engine(engine);
245 base64::Engine::encode(&base64::engine::general_purpose::STANDARD, hash.as_byte_array())
246}
247
248/// Find a free port to use for the server.
249fn find_free_port() -> u16 {
250 use std::net::TcpListener;
251
252 let listener = TcpListener::bind("127.0.0.1:0").expect("Failed to bind to random port");
253 let port = listener
254 .local_addr()
255 .expect("Failed to get local addr")
256 .port();
257 drop(listener);
258 port
259}
260
261/// Wait for the server to be ready to accept connections.
262async fn wait_for_server_ready(port: u16) {
263 let max_attempts = 50; // 5 seconds total
264 let delay = std::time::Duration::from_millis(100);
265
266 for attempt in 0..max_attempts {
267 match tokio::net::TcpStream::connect(format!("127.0.0.1:{}", port)).await {
268 Ok(_) => {
269 // Connection successful, server is ready
270 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
271 return;
272 }
273 Err(_) => {
274 if attempt == max_attempts - 1 {
275 panic!(
276 "MockRelay failed to start after {} attempts",
277 max_attempts
278 );
279 }
280 tokio::time::sleep(delay).await;
281 }
282 }
283 }
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289 use nostr_sdk::prelude::*;
290 use std::time::Duration;
291
292 #[tokio::test]
293 async fn test_mock_relay_starts_and_stops() {
294 let mock = MockRelay::start().await;
295
296 // Verify URL is set
297 assert!(mock.url().starts_with("ws://127.0.0.1:"));
298
299 mock.stop().await;
300 }
301
302 #[tokio::test]
303 async fn test_mock_relay_accepts_events() {
304 let mock = MockRelay::start().await;
305
306 // Create a client and connect
307 let keys = Keys::generate();
308 let client = Client::new(keys.clone());
309 client.add_relay(mock.url()).await.expect("Failed to add relay");
310 client.connect().await;
311
312 // Wait for connection
313 tokio::time::sleep(Duration::from_millis(500)).await;
314
315 // Create and send a simple event
316 let event = EventBuilder::text_note("Test note from MockRelay test")
317 .sign_with_keys(&keys)
318 .expect("Failed to sign event");
319
320 let result = client.send_event(&event).await;
321 assert!(result.is_ok(), "MockRelay should accept events");
322
323 // Verify event was stored by fetching it back
324 let filter = Filter::new().id(event.id);
325 let events = client
326 .fetch_events(filter, Duration::from_secs(2))
327 .await
328 .expect("Failed to fetch events");
329
330 assert!(!events.is_empty(), "Event should be stored and retrievable");
331 assert_eq!(events.first().unwrap().id, event.id);
332
333 client.disconnect().await;
334 mock.stop().await;
335 }
336}
diff --git a/tests/common/mod.rs b/tests/common/mod.rs
index e70bd71..32ce1b7 100644
--- a/tests/common/mod.rs
+++ b/tests/common/mod.rs
@@ -3,11 +3,13 @@
3#![allow(unused_imports)] // Re-exports may not be used in all test configurations 3#![allow(unused_imports)] // Re-exports may not be used in all test configurations
4 4
5pub mod git_server; 5pub mod git_server;
6pub mod mock_relay;
6pub mod purgatory_helpers; 7pub mod purgatory_helpers;
7pub mod relay; 8pub mod relay;
8pub mod sync_helpers; 9pub mod sync_helpers;
9 10
10pub use git_server::SimpleGitServer; 11pub use git_server::SimpleGitServer;
12pub use mock_relay::MockRelay;
11pub use purgatory_helpers::*; 13pub use purgatory_helpers::*;
12pub use relay::TestRelay; 14pub use relay::TestRelay;
13pub use sync_helpers::*; 15pub use sync_helpers::*;
diff --git a/tests/common/purgatory_helpers.rs b/tests/common/purgatory_helpers.rs
index 7d8e908..fa1be73 100644
--- a/tests/common/purgatory_helpers.rs
+++ b/tests/common/purgatory_helpers.rs
@@ -271,6 +271,60 @@ pub fn create_pr_event(
271 .map_err(|e| format!("Failed to sign PR event: {}", e)) 271 .map_err(|e| format!("Failed to sign PR event: {}", e))
272} 272}
273 273
274/// Create a PR event (kind 1618) with clone URLs.
275///
276/// Creates a properly formatted NIP-34 PR event that references a repository
277/// via an `a` tag, includes the commit hash via a `c` tag, and specifies
278/// clone URLs where the PR commit can be fetched from.
279///
280/// Per NIP-34, PR events can include a `clone` tag:
281/// ```jsonc
282/// {
283/// "kind": 1618,
284/// "tags": [
285/// ["c", "<current-commit-id>"],
286/// ["clone", "<clone-url>", ...], // at least one git clone url where commit can be downloaded
287/// // ...
288/// ]
289/// }
290/// ```
291///
292/// # Arguments
293/// * `keys` - Keys for signing
294/// * `repo_coord` - Repository coordinate (format: "30617:pubkey_hex:identifier")
295/// * `commit_hash` - The commit hash (c-tag)
296/// * `title` - PR title (used as content)
297/// * `clone_urls` - Clone URLs where the PR commit can be fetched
298///
299/// # Returns
300/// * `Ok(Event)` - Signed PR event ready to send
301/// * `Err(String)` - If signing fails
302pub fn create_pr_event_with_clone(
303 keys: &Keys,
304 repo_coord: &str,
305 commit_hash: &str,
306 title: &str,
307 clone_urls: &[&str],
308) -> Result<Event, String> {
309 let mut tags = vec![
310 // a-tag referencing the repository
311 Tag::custom(TagKind::custom("a"), vec![repo_coord.to_string()]),
312 // c-tag with the commit hash
313 Tag::custom(TagKind::custom("c"), vec![commit_hash.to_string()]),
314 ];
315
316 // Add clone URLs if provided
317 if !clone_urls.is_empty() {
318 let urls: Vec<String> = clone_urls.iter().map(|s| s.to_string()).collect();
319 tags.push(Tag::custom(TagKind::Clone, urls));
320 }
321
322 EventBuilder::new(Kind::Custom(KIND_PR), title)
323 .tags(tags)
324 .sign_with_keys(keys)
325 .map_err(|e| format!("Failed to sign PR event: {}", e))
326}
327
274/// Build a repository coordinate string for use in 'a' tags. 328/// Build a repository coordinate string for use in 'a' tags.
275/// 329///
276/// Format: `30617:pubkey_hex:identifier` 330/// Format: `30617:pubkey_hex:identifier`
@@ -738,4 +792,65 @@ mod tests {
738 let branch_commit = String::from_utf8_lossy(&output.stdout).trim().to_string(); 792 let branch_commit = String::from_utf8_lossy(&output.stdout).trim().to_string();
739 assert_eq!(branch_commit, commit_hash); 793 assert_eq!(branch_commit, commit_hash);
740 } 794 }
795
796 #[test]
797 fn test_create_pr_event_with_clone_has_correct_tags() {
798 let keys = Keys::generate();
799 let repo_coord = build_repo_coord(&keys, "test-repo");
800 let event = create_pr_event_with_clone(
801 &keys,
802 &repo_coord,
803 "abc123def456",
804 "Test PR with clone",
805 &["http://fork-server.com/repo.git", "http://another-server.com/repo.git"],
806 )
807 .expect("Failed to create PR event with clone");
808
809 assert_eq!(event.kind.as_u16(), KIND_PR);
810
811 // Check a-tag
812 let has_a_tag = event.tags.iter().any(|tag| {
813 let slice = tag.as_slice();
814 slice.first().is_some_and(|t| t == "a") && slice.get(1).is_some_and(|v| v == &repo_coord)
815 });
816 assert!(has_a_tag, "Event should have 'a' tag");
817
818 // Check c-tag
819 let has_c_tag = event.tags.iter().any(|tag| {
820 let slice = tag.as_slice();
821 slice.first().is_some_and(|t| t == "c")
822 && slice.get(1).is_some_and(|v| v == "abc123def456")
823 });
824 assert!(has_c_tag, "Event should have 'c' tag with commit");
825
826 // Check clone tag with both URLs
827 let has_clone_tag = event.tags.iter().any(|tag| {
828 let slice = tag.as_slice();
829 slice.first().is_some_and(|t| t == "clone")
830 && slice.get(1).is_some_and(|v| v == "http://fork-server.com/repo.git")
831 && slice.get(2).is_some_and(|v| v == "http://another-server.com/repo.git")
832 });
833 assert!(has_clone_tag, "Event should have 'clone' tag with URLs");
834 }
835
836 #[test]
837 fn test_create_pr_event_with_clone_empty_urls() {
838 let keys = Keys::generate();
839 let repo_coord = build_repo_coord(&keys, "test-repo");
840 let event = create_pr_event_with_clone(
841 &keys,
842 &repo_coord,
843 "abc123def456",
844 "Test PR without clone URLs",
845 &[], // Empty clone URLs
846 )
847 .expect("Failed to create PR event");
848
849 // Should not have clone tag when no URLs provided
850 let has_clone_tag = event.tags.iter().any(|tag| {
851 let slice = tag.as_slice();
852 slice.first().is_some_and(|t| t == "clone")
853 });
854 assert!(!has_clone_tag, "Event should not have 'clone' tag when no URLs provided");
855 }
741} 856}