commit df336e5e718071873f6e46d4d0b46de593427bb9
parent 8d434ef71f5d39cd0b8700b54c4ab824ea456d22
Author: triesap <tyson@radroots.org>
Date: Sat, 6 Jun 2026 13:21:28 -0700
tests: add nip01 conformance suite
- add reusable relay conformance test harness
- exercise NIP-01 EVENT, REQ, EOSE, CLOSE, and NOTICE flow
- authenticate with canonical Radroots Nostr event builders
- verify accepted events through direct SurrealDB state
Diffstat:
2 files changed, 328 insertions(+), 0 deletions(-)
diff --git a/crates/tangle/tests/nip01_conformance.rs b/crates/tangle/tests/nip01_conformance.rs
@@ -0,0 +1,62 @@
+#![forbid(unsafe_code)]
+
+mod support;
+
+use std::fs;
+use support::{
+ RelayHarness, assert_ok, close_subscription, connect_client, http_get, next_label,
+ reopen_store, request_event_by_id, send_auth, send_event, send_text,
+};
+use tangle_test_support::{
+ FixtureKey, auth_event_spec, build_fixture_event, valid_public_listing_spec,
+};
+
+#[tokio::test]
+async fn nip01_conformance_event_req_eose_and_close_round_trip() {
+ let seller = FixtureKey::Seller.public_key();
+ let harness = RelayHarness::start(
+ "nip01_conformance",
+ serde_json::json!({
+ "approved_sellers": [seller.as_str()]
+ }),
+ );
+ let readiness = http_get(harness.port, "/readyz");
+ assert!(readiness.contains("200 OK"));
+ assert!(readiness.contains("\"status\":\"ready\""));
+
+ let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
+ let auth = build_fixture_event(&auth_event_spec()).expect("auth");
+ let mut client = connect_client(harness.port).await;
+ let notice = send_text(&mut client, "not json").await;
+ assert_eq!(notice[0], "NOTICE");
+ assert!(
+ notice[1]
+ .as_str()
+ .expect("notice")
+ .starts_with("invalid: client message JSON is invalid:")
+ );
+
+ assert_ok(&send_auth(&mut client, &auth).await, true);
+ assert_ok(&send_event(&mut client, &listing).await, true);
+ let fetched = request_event_by_id(&mut client, "nip01-fetch", &listing).await;
+ assert_eq!(fetched[0], "EVENT");
+ assert_eq!(fetched[1], "nip01-fetch");
+ assert_eq!(fetched[2]["id"], listing.id().as_str());
+ assert_eq!(next_label(&mut client).await, "EOSE");
+ close_subscription(&mut client, "nip01-fetch").await;
+
+ let store_config = harness.store_config();
+ let root = harness.root.clone();
+ drop(client);
+ harness.stop();
+ let store = reopen_store(&store_config).await;
+ assert!(
+ store
+ .raw_event_row(listing.id())
+ .await
+ .expect("raw row")
+ .is_some()
+ );
+ drop(store);
+ fs::remove_dir_all(root).expect("remove runtime root");
+}
diff --git a/crates/tangle/tests/support/mod.rs b/crates/tangle/tests/support/mod.rs
@@ -0,0 +1,266 @@
+use futures_util::{SinkExt, StreamExt};
+use serde_json::Value;
+use std::fs;
+use std::io::{Read, Write};
+use std::net::{TcpListener, TcpStream};
+use std::path::{Path, PathBuf};
+use std::process::{Child, Command, Stdio};
+use std::time::{Duration, Instant};
+use tangle_protocol::{Event, event_to_value};
+use tangle_store_surreal::{SurrealConnectionConfig, SurrealStore};
+use tokio_tungstenite::tungstenite::Message;
+
+pub type RelayClient =
+ tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
+
+pub struct RelayHarness {
+ pub port: u16,
+ pub root: PathBuf,
+ pub db_path: PathBuf,
+ pub namespace: String,
+ child: Child,
+}
+
+impl RelayHarness {
+ pub fn start(namespace: &str, policy: Value) -> Self {
+ let port = free_port();
+ let root = std::env::temp_dir().join(format!(
+ "tangle-conformance-{namespace}-{}-{port}",
+ std::process::id()
+ ));
+ let db_path = root.join("surrealdb");
+ let config_path = root.join("runtime.json");
+ fs::create_dir_all(&root).expect("runtime root");
+ write_runtime_config(&config_path, &db_path, port, namespace, policy);
+ let mut child = Command::new(env!("CARGO_BIN_EXE_tangle"))
+ .args(["run", "--config"])
+ .arg(&config_path)
+ .stdout(Stdio::null())
+ .stderr(Stdio::piped())
+ .spawn()
+ .expect("spawn tangle run");
+ wait_for_http(port, &mut child);
+ Self {
+ port,
+ root,
+ db_path,
+ namespace: namespace.to_owned(),
+ child,
+ }
+ }
+
+ pub fn store_config(&self) -> SurrealConnectionConfig {
+ SurrealConnectionConfig::rocksdb(
+ self.db_path.to_str().expect("db path"),
+ &self.namespace,
+ "relay",
+ )
+ .expect("store config")
+ }
+
+ pub fn stop(self) {
+ stop_relay(self.child);
+ }
+}
+
+pub async fn connect_client(port: u16) -> RelayClient {
+ let (mut client, _) = tokio_tungstenite::connect_async(format!("ws://127.0.0.1:{port}/ws"))
+ .await
+ .expect("client connect");
+ assert_eq!(next_label(&mut client).await, "AUTH");
+ client
+}
+
+pub async fn send_event(client: &mut RelayClient, event: &Event) -> Value {
+ client
+ .send(Message::Text(
+ serde_json::json!(["EVENT", event_to_value(event)])
+ .to_string()
+ .into(),
+ ))
+ .await
+ .expect("event send");
+ next_json(client).await
+}
+
+pub async fn send_auth(client: &mut RelayClient, event: &Event) -> Value {
+ client
+ .send(Message::Text(
+ serde_json::json!(["AUTH", event_to_value(event)])
+ .to_string()
+ .into(),
+ ))
+ .await
+ .expect("auth send");
+ next_json(client).await
+}
+
+pub async fn request_event_by_id(
+ client: &mut RelayClient,
+ subscription: &str,
+ event: &Event,
+) -> Value {
+ client
+ .send(Message::Text(
+ serde_json::json!(["REQ", subscription, { "ids": [event.id().as_str()] }])
+ .to_string()
+ .into(),
+ ))
+ .await
+ .expect("req send");
+ next_json(client).await
+}
+
+pub async fn close_subscription(client: &mut RelayClient, subscription: &str) {
+ client
+ .send(Message::Text(
+ serde_json::json!(["CLOSE", subscription])
+ .to_string()
+ .into(),
+ ))
+ .await
+ .expect("close send");
+}
+
+pub async fn send_text(client: &mut RelayClient, text: &str) -> Value {
+ client
+ .send(Message::Text(text.to_owned().into()))
+ .await
+ .expect("text send");
+ next_json(client).await
+}
+
+pub async fn next_json(client: &mut RelayClient) -> Value {
+ let message = client
+ .next()
+ .await
+ .expect("websocket message")
+ .expect("websocket frame");
+ let text = message.into_text().expect("text frame");
+ serde_json::from_str(&text).expect("relay JSON")
+}
+
+pub async fn next_label(client: &mut RelayClient) -> String {
+ next_json(client).await[0]
+ .as_str()
+ .expect("label")
+ .to_owned()
+}
+
+pub fn assert_ok(message: &Value, accepted: bool) {
+ assert_eq!(message[0], "OK");
+ assert_eq!(message[2], accepted, "relay OK frame: {message}");
+}
+
+pub fn http_get(port: u16, path: &str) -> String {
+ try_http_get(port, path).expect("http get")
+}
+
+pub async fn reopen_store(config: &SurrealConnectionConfig) -> SurrealStore {
+ let started = Instant::now();
+ loop {
+ match SurrealStore::connect_local(config).await {
+ Ok(store) => return store,
+ Err(error) if started.elapsed() < Duration::from_secs(5) => {
+ let _ = error;
+ tokio::time::sleep(Duration::from_millis(50)).await;
+ }
+ Err(error) => panic!("store reopen failed: {error}"),
+ }
+ }
+}
+
+fn write_runtime_config(path: &Path, db_path: &Path, port: u16, namespace: &str, policy: Value) {
+ let config = serde_json::json!({
+ "server": {
+ "listen_addr": format!("127.0.0.1:{port}"),
+ "relay_url": "wss://relay.radroots.test"
+ },
+ "database": {
+ "mode": "rocks_db",
+ "path": db_path.to_str().expect("db path"),
+ "namespace": namespace,
+ "database": "relay"
+ },
+ "auth": {
+ "challenge_ttl_seconds": 300
+ },
+ "limits": {
+ "message_rate_limit": {
+ "limit": 120,
+ "window_seconds": 60
+ }
+ },
+ "policy": policy
+ });
+ fs::write(
+ path,
+ serde_json::to_string_pretty(&config).expect("config JSON"),
+ )
+ .expect("write config");
+}
+
+fn free_port() -> u16 {
+ TcpListener::bind("127.0.0.1:0")
+ .expect("bind port")
+ .local_addr()
+ .expect("local addr")
+ .port()
+}
+
+fn wait_for_http(port: u16, child: &mut Child) {
+ let started = Instant::now();
+ loop {
+ if let Ok(response) = try_http_get(port, "/healthz")
+ && response.contains("200 OK")
+ {
+ return;
+ }
+ if let Some(status) = child.try_wait().expect("child status") {
+ panic!("relay exited before readiness: {status}");
+ }
+ assert!(
+ started.elapsed() < Duration::from_secs(10),
+ "relay did not open port {port}"
+ );
+ std::thread::sleep(Duration::from_millis(50));
+ }
+}
+
+fn try_http_get(port: u16, path: &str) -> Result<String, std::io::Error> {
+ let mut stream = TcpStream::connect(("127.0.0.1", port))?;
+ stream.set_read_timeout(Some(Duration::from_secs(2)))?;
+ stream.set_write_timeout(Some(Duration::from_secs(2)))?;
+ write!(
+ stream,
+ "GET {path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nAccept: application/nostr+json\r\nConnection: close\r\n\r\n"
+ )?;
+ let mut response = String::new();
+ stream.read_to_string(&mut response)?;
+ Ok(response)
+}
+
+fn stop_relay(relay: Child) {
+ let _ = stop_relay_with_stderr(relay);
+}
+
+fn stop_relay_with_stderr(mut relay: Child) -> String {
+ stop_child(&mut relay);
+ let output = relay.wait_with_output().expect("relay exit");
+ assert!(output.status.success());
+ String::from_utf8_lossy(&output.stderr).to_string()
+}
+
+#[cfg(unix)]
+fn stop_child(relay: &mut Child) {
+ let status = Command::new("kill")
+ .args(["-INT", &relay.id().to_string()])
+ .status()
+ .expect("send interrupt");
+ assert!(status.success());
+}
+
+#[cfg(not(unix))]
+fn stop_child(relay: &mut Child) {
+ relay.kill().expect("kill relay");
+}