version.rs (13690B)
1 #![forbid(unsafe_code)] 2 3 use std::{ 4 io::{Read, Write}, 5 net::{SocketAddr, TcpListener, TcpStream}, 6 process::{Child, Command, Output, Stdio}, 7 time::{Duration, Instant}, 8 }; 9 use tangle_test_support::{FixtureKey, TANGLE_V2_RELAY_SECRET_HEX}; 10 11 #[test] 12 fn tangle_version_command_reports_package_version() { 13 let output = Command::new(env!("CARGO_BIN_EXE_tangle")) 14 .arg("--version") 15 .output() 16 .expect("run tangle --version"); 17 18 assert!(output.status.success()); 19 assert_eq!(String::from_utf8_lossy(&output.stdout), "tangle 0.1.0\n"); 20 assert!(output.stderr.is_empty()); 21 } 22 23 #[test] 24 fn tangle_without_args_reports_usage() { 25 let output = Command::new(env!("CARGO_BIN_EXE_tangle")) 26 .output() 27 .expect("run tangle without args"); 28 29 assert!(output.status.success()); 30 assert_eq!( 31 String::from_utf8_lossy(&output.stdout), 32 format!("{}\n", tangle::usage_output()) 33 ); 34 assert!(output.stderr.is_empty()); 35 } 36 37 #[test] 38 fn tangle_unknown_arg_reports_usage_error() { 39 let output = Command::new(env!("CARGO_BIN_EXE_tangle")) 40 .arg("--unknown") 41 .output() 42 .expect("run tangle unknown arg"); 43 44 assert_eq!(output.status.code(), Some(2)); 45 assert!(output.stdout.is_empty()); 46 assert_eq!( 47 String::from_utf8_lossy(&output.stderr), 48 format!("unknown command: --unknown\n{}\n", tangle::usage_output()) 49 ); 50 } 51 52 #[test] 53 fn tangle_removed_commands_are_not_accepted() { 54 for args in [ 55 vec!["migrate"], 56 vec!["event", "import"], 57 vec!["event", "export"], 58 vec!["projection", "rebuild"], 59 vec!["ops", "backup"], 60 vec!["ops", "restore"], 61 ] { 62 let output = Command::new(env!("CARGO_BIN_EXE_tangle")) 63 .args(args) 64 .output() 65 .expect("run tangle removed command"); 66 67 assert_eq!(output.status.code(), Some(2)); 68 assert!(output.stdout.is_empty()); 69 assert!(String::from_utf8_lossy(&output.stderr).contains("unknown command")); 70 } 71 } 72 73 #[test] 74 fn tangle_run_reports_missing_config() { 75 let output = Command::new(env!("CARGO_BIN_EXE_tangle")) 76 .args(["run"]) 77 .output() 78 .expect("run tangle without config"); 79 80 assert_eq!(output.status.code(), Some(2)); 81 assert!(output.stdout.is_empty()); 82 assert_eq!( 83 String::from_utf8_lossy(&output.stderr), 84 "--config requires a value\n" 85 ); 86 } 87 88 #[test] 89 fn tangle_run_starts_server_and_stays_alive_until_shutdown() { 90 let root = std::env::temp_dir().join(format!("tangle-cli-run-{}", std::process::id())); 91 let _ = std::fs::remove_dir_all(&root); 92 std::fs::create_dir_all(&root).expect("runtime root"); 93 let data_dir = root.join("pocket"); 94 let tenant_dir = root.join("tenants"); 95 std::fs::create_dir_all(&tenant_dir).expect("tenant dir"); 96 let config_path = root.join("host.json"); 97 let tenant_config_path = tenant_dir.join("farmers_market.json"); 98 let listen_addr = reserve_loopback_addr(); 99 std::fs::write( 100 &config_path, 101 serde_json::json!({ 102 "listen_addr": listen_addr.to_string(), 103 "tenant_config_dir": "tenants", 104 "limits": { 105 "max_total_connections": 10000, 106 "max_total_subscriptions": 25000, 107 "tenant_startup_concurrency": 4 108 } 109 }) 110 .to_string(), 111 ) 112 .expect("write host config"); 113 std::fs::write( 114 &tenant_config_path, 115 serde_json::json!({ 116 "tenant_id": "farmers-market", 117 "tenant_schema": "farmers_market", 118 "host": "relay.radroots.test", 119 "relay_url": "wss://relay.radroots.test", 120 "inactive": false, 121 "info": { 122 "name": "Radroots Farmers Market" 123 }, 124 "pocket": { 125 "data_directory": data_dir, 126 "sync_policy": "flush_on_shutdown" 127 }, 128 "pocket_query": { 129 "allow_scraping": false, 130 "allow_scrape_if_limited_to": 100, 131 "allow_scrape_if_max_seconds": 3600 132 }, 133 "groups": { 134 "enabled": true, 135 "canonical_relay_url": "wss://relay.radroots.test", 136 "relay_secret": TANGLE_V2_RELAY_SECRET_HEX, 137 "owner_pubkeys": [FixtureKey::Owner.public_key().as_str()], 138 "admin_pubkeys": [FixtureKey::Admin.public_key().as_str()], 139 "limits": { 140 "max_group_id_bytes": 128, 141 "max_group_tags_per_event": 8, 142 "max_supported_kinds": 512, 143 "max_member_list_pubkeys": 100000, 144 "max_outbox_replay_batch": 1000 145 } 146 }, 147 "backup_export": { 148 "backup_enabled": true, 149 "export_enabled": true 150 }, 151 "auth": { 152 "challenge_ttl_seconds": 300, 153 "created_at_skew_seconds": 600 154 }, 155 "limits": { 156 "max_message_length": 1048576, 157 "max_subid_length": 64, 158 "max_subscriptions_per_connection": 64, 159 "max_filters_per_request": 10, 160 "max_tag_values_per_filter": 100, 161 "max_query_complexity": 2048, 162 "max_limit": 500, 163 "default_limit": 100, 164 "max_event_tags": 200, 165 "max_content_length": 65536, 166 "broadcast_channel_capacity": 4096, 167 "per_connection_outbound_queue": 256 168 }, 169 "rate_limits": { 170 "auth": { 171 "per_ip": {"window_seconds": 60, "max_hits": 120}, 172 "per_pubkey": {"window_seconds": 60, "max_hits": 30}, 173 "failures": {"window_seconds": 300, "max_hits": 5}, 174 "failures_per_ip": {"window_seconds": 300, "max_hits": 20} 175 }, 176 "event": { 177 "per_ip": {"window_seconds": 60, "max_hits": 600}, 178 "per_pubkey": {"window_seconds": 60, "max_hits": 120}, 179 "per_kind": {"window_seconds": 60, "max_hits": 1000} 180 }, 181 "group": { 182 "write_per_ip": {"window_seconds": 60, "max_hits": 300}, 183 "write_per_pubkey": {"window_seconds": 60, "max_hits": 60}, 184 "write_per_group": {"window_seconds": 60, "max_hits": 90}, 185 "write_per_kind": {"window_seconds": 60, "max_hits": 300}, 186 "join_flow": {"window_seconds": 300, "max_hits": 10}, 187 "join_flow_per_ip": {"window_seconds": 300, "max_hits": 30} 188 }, 189 "req": { 190 "per_ip": {"window_seconds": 60, "max_hits": 600}, 191 "per_connection": {"window_seconds": 60, "max_hits": 120}, 192 "per_pubkey": {"window_seconds": 60, "max_hits": 240}, 193 "per_group": {"window_seconds": 60, "max_hits": 240}, 194 "per_kind": {"window_seconds": 60, "max_hits": 500}, 195 "broad": {"window_seconds": 60, "max_hits": 30} 196 }, 197 "count": { 198 "per_ip": {"window_seconds": 60, "max_hits": 300}, 199 "per_connection": {"window_seconds": 60, "max_hits": 60}, 200 "per_pubkey": {"window_seconds": 60, "max_hits": 120}, 201 "per_group": {"window_seconds": 60, "max_hits": 120}, 202 "per_kind": {"window_seconds": 60, "max_hits": 240}, 203 "broad": {"window_seconds": 60, "max_hits": 20} 204 } 205 } 206 }) 207 .to_string(), 208 ) 209 .expect("write config"); 210 211 let mut child = TangleChild::spawn(&config_path); 212 let ready = wait_for_http_ok(listen_addr, "/.well-known/tangle/ready", None); 213 let metrics = wait_for_http_ok(listen_addr, "/.well-known/tangle/metrics", None); 214 let tenants = wait_for_http_ok(listen_addr, "/.well-known/tangle/tenants", None); 215 let nip11 = wait_for_http_ok(listen_addr, "/", Some("application/nostr+json")); 216 let ready_value = 217 serde_json::from_str::<serde_json::Value>(response_body(&ready)).expect("ready json"); 218 let metrics_value = 219 serde_json::from_str::<serde_json::Value>(response_body(&metrics)).expect("metrics json"); 220 let tenants_value = 221 serde_json::from_str::<serde_json::Value>(response_body(&tenants)).expect("tenants json"); 222 let nip11_value = 223 serde_json::from_str::<serde_json::Value>(response_body(&nip11)).expect("nip11 json"); 224 225 assert_eq!(ready_value["status"], "ready"); 226 assert_eq!(ready_value["checks"]["active_tenants"], "ready"); 227 assert_eq!(metrics_value["tangle_host_configured_tenants"], 1); 228 assert_eq!(metrics_value["tangle_host_active_tenants"], 1); 229 assert_eq!(tenants_value["tenants"][0]["tenant_id"], "farmers-market"); 230 assert_eq!(tenants_value["tenants"][0]["ready"], true); 231 assert_eq!(nip11_value["name"], "Radroots Farmers Market"); 232 assert_eq!(nip11_value["limitation"]["max_message_length"], 1_048_576); 233 assert_eq!(nip11_value["limitation"]["max_subscriptions"], 64); 234 assert_eq!(nip11_value["limitation"]["max_filters"], 10); 235 assert_eq!(nip11_value["limitation"]["max_limit"], 500); 236 assert_eq!(nip11_value["limitation"]["max_query_complexity"], 2_048); 237 assert_eq!(nip11_value["limitation"]["max_subid_length"], 64); 238 assert_eq!(nip11_value["limitation"]["max_event_tags"], 200); 239 assert_eq!(nip11_value["limitation"]["max_content_length"], 65_536); 240 assert_eq!(nip11_value["limitation"]["auth_required"], false); 241 assert_eq!(nip11_value["limitation"]["payment_required"], false); 242 assert_eq!(nip11_value["limitation"]["restricted_writes"], true); 243 assert_eq!(nip11_value["limitation"]["default_limit"], 100); 244 assert_eq!(nip11_value["retention"]["physical_erasure"], false); 245 assert_eq!(nip11_value["retention"]["compaction_guarantee"], false); 246 assert!( 247 nip11_value["supported_nips"] 248 .as_array() 249 .expect("supported nips") 250 .contains(&serde_json::json!(29)) 251 ); 252 assert!(child.try_wait().expect("child status").is_none()); 253 assert!(data_dir.exists()); 254 255 let output = child.stop().expect("stop child"); 256 257 assert!(output.stdout.is_empty()); 258 let stderr = String::from_utf8(output.stderr).expect("stderr utf8"); 259 assert!(stderr.contains(r#""event":"runtime_config_loaded""#)); 260 assert!(stderr.contains(r#""relay_secret":"<redacted>""#)); 261 assert!(!stderr.contains(TANGLE_V2_RELAY_SECRET_HEX)); 262 263 std::fs::remove_dir_all(&root).expect("remove runtime root"); 264 } 265 266 struct TangleChild { 267 child: Option<Child>, 268 } 269 270 impl TangleChild { 271 fn spawn(config_path: &std::path::Path) -> Self { 272 let child = Command::new(env!("CARGO_BIN_EXE_tangle")) 273 .args(["run", "--config"]) 274 .arg(config_path) 275 .stdout(Stdio::piped()) 276 .stderr(Stdio::piped()) 277 .spawn() 278 .expect("spawn tangle"); 279 Self { child: Some(child) } 280 } 281 282 fn try_wait(&mut self) -> std::io::Result<Option<std::process::ExitStatus>> { 283 self.child.as_mut().expect("child").try_wait() 284 } 285 286 fn stop(mut self) -> std::io::Result<Output> { 287 let mut child = self.child.take().expect("child"); 288 let kill_error = child.kill().err(); 289 let output = child.wait_with_output(); 290 if let Some(error) = kill_error 291 && error.kind() != std::io::ErrorKind::InvalidInput 292 { 293 return Err(error); 294 } 295 output 296 } 297 } 298 299 impl Drop for TangleChild { 300 fn drop(&mut self) { 301 if let Some(child) = &mut self.child { 302 let _ = child.kill(); 303 let _ = child.wait(); 304 } 305 } 306 } 307 308 fn reserve_loopback_addr() -> SocketAddr { 309 let listener = TcpListener::bind("127.0.0.1:0").expect("reserve loopback address"); 310 listener.local_addr().expect("loopback address") 311 } 312 313 fn wait_for_http_ok(address: SocketAddr, path: &str, accept: Option<&str>) -> String { 314 let deadline = Instant::now() + Duration::from_secs(5); 315 let mut last_error = String::new(); 316 while Instant::now() < deadline { 317 match http_get(address, path, accept) { 318 Ok(response) if response.starts_with("HTTP/1.1 200 OK") => return response, 319 Ok(response) => { 320 last_error = response.lines().next().unwrap_or("").to_owned(); 321 } 322 Err(error) => { 323 last_error = error.to_string(); 324 } 325 } 326 std::thread::sleep(Duration::from_millis(50)); 327 } 328 panic!("server did not answer {path}: {last_error}"); 329 } 330 331 fn http_get(address: SocketAddr, path: &str, accept: Option<&str>) -> std::io::Result<String> { 332 let mut stream = TcpStream::connect_timeout(&address, Duration::from_millis(200))?; 333 stream.set_read_timeout(Some(Duration::from_millis(500)))?; 334 stream.set_write_timeout(Some(Duration::from_millis(500)))?; 335 let mut request = format!("GET {path} HTTP/1.1\r\nHost: relay.radroots.test\r\n"); 336 if let Some(accept) = accept { 337 request.push_str("Accept: "); 338 request.push_str(accept); 339 request.push_str("\r\n"); 340 } 341 request.push_str("Connection: close\r\n\r\n"); 342 stream.write_all(request.as_bytes())?; 343 let mut response = String::new(); 344 stream.read_to_string(&mut response)?; 345 Ok(response) 346 } 347 348 fn response_body(response: &str) -> &str { 349 response.split_once("\r\n\r\n").expect("response body").1 350 }