tangle


git clone https://radroots.dev/git/tangle.git
Log | Files | Refs | README | LICENSE

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 }