mod common; use std::fs; use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; use axum::routing::get; use axum::{Json, Router}; use common::{ client, collection, config_for, poll_messages, publish, query_envelope, register, spawn_relay, }; use frxd::crypto::{Keypair, now_ts}; use frxd::index::LocalIndex; use frxd::message::{EXPOSURE_FULL, TYPE_RESPONSE}; use frxd::node::{Node, NodeHandle}; use frxd::registry::{self, KeyEntry, RegistryDoc, RegistryMember, SignedRegistry}; use serde_json::Value; fn member_entry( id: &str, key: &Keypair, class: &str, not_before: u64, not_after: Option, ) -> RegistryMember { RegistryMember { id: id.to_string(), class: class.to_string(), keys: vec![KeyEntry { key: key.public_hex(), not_before, not_after, }], } } fn ma_registry(ma: &Keypair, members: Vec, version: u64) -> SignedRegistry { registry::sign_registry( RegistryDoc { version, issued_at: now_ts(), ma_key: String::new(), members, }, ma, ) } fn config_with_registry( dir: &Path, name: &str, relay_url: &str, registry: &str, ma_key: &str, ) -> frxd::config::Config { let mut config = config_for(dir, name, relay_url); config.node.registry = Some(registry.to_string()); config.node.ma_key = Some(ma_key.to_string()); config.node.dev_bootstrap = false; config } async fn bob_with_docs(root: &Path, relay_url: &str, registry: &str, ma_key: &str) -> NodeHandle { let docs = root.join("bob-docs"); fs::create_dir_all(&docs).unwrap(); fs::write(docs.join("doc.txt"), "registry rust document").unwrap(); let config = config_with_registry(&root.join("bob"), "bob", relay_url, registry, ma_key); { let index = LocalIndex::open(&config.index_dir()).unwrap(); index .add_collection(&collection("docs", &docs, true, EXPOSURE_FULL)) .unwrap(); } Node::start(config).await.unwrap() } async fn responses_for( http: &reqwest::Client, relay_url: &str, asker: &Keypair, identifier: &str, text: &str, timeout_ms: u64, ) -> Vec { register(http, relay_url, asker).await; let envelope = query_envelope(asker, identifier, text, 5); assert!( publish(http, relay_url, &envelope) .await .status() .is_success() ); let start = Instant::now(); let deadline = Duration::from_millis(timeout_ms); let mut responses = Vec::new(); while start.elapsed() < deadline { let messages = poll_messages(http, relay_url, asker, 200).await; responses.extend( messages .iter() .filter(|m| m.get("type").and_then(Value::as_str) == Some(TYPE_RESPONSE)) .cloned(), ); if !responses.is_empty() { break; } } responses } #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn registry_gates_membership_and_revocation_propagates() { let root = tempfile::tempdir().unwrap(); let relay_url = spawn_relay().await; let ma = Keypair::generate(); let alice = Keypair::generate(); let registry_path = root.path().join("registry.json"); registry::save_registry( ®istry_path, &ma_registry( &ma, vec![member_entry("alice.frx.example", &alice, "source", 0, None)], 1, ), ) .unwrap(); let _bob = bob_with_docs( root.path(), &relay_url, ®istry_path.display().to_string(), &ma.public_hex(), ) .await; let http = client(); let accepted = responses_for(&http, &relay_url, &alice, "alice.frx.example", "rust", 700).await; assert_eq!(accepted.len(), 1, "listed member got no response"); let stranger = Keypair::generate(); let denied = responses_for( &http, &relay_url, &stranger, &stranger.public_hex(), "rust", 400, ) .await; assert!(denied.is_empty(), "unlisted key was answered"); tokio::time::sleep(Duration::from_millis(20)).await; registry::save_registry(®istry_path, &ma_registry(&ma, Vec::new(), 2)).unwrap(); let revoked = responses_for(&http, &relay_url, &alice, "alice.frx.example", "rust", 500).await; assert!(revoked.is_empty(), "revoked member still answered"); tokio::time::sleep(Duration::from_millis(20)).await; registry::save_registry( ®istry_path, &ma_registry( &ma, vec![member_entry("alice.frx.example", &alice, "source", 0, None)], 1, ), ) .unwrap(); let rolled_back = responses_for(&http, &relay_url, &alice, "alice.frx.example", "rust", 500).await; assert!( rolled_back.is_empty(), "registry rollback re-admitted a revoked member" ); } #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn forged_or_wrong_key_snapshot_closes_the_registry() { let root = tempfile::tempdir().unwrap(); let relay_url = spawn_relay().await; let ma = Keypair::generate(); let attacker = Keypair::generate(); let alice = Keypair::generate(); let registry_path = root.path().join("registry.json"); registry::save_registry( ®istry_path, &ma_registry( &attacker, vec![member_entry("alice.frx.example", &alice, "source", 0, None)], 1, ), ) .unwrap(); let _bob = bob_with_docs( root.path(), &relay_url, ®istry_path.display().to_string(), &ma.public_hex(), ) .await; let http = client(); let denied = responses_for(&http, &relay_url, &alice, "alice.frx.example", "rust", 400).await; assert!( denied.is_empty(), "snapshot signed by wrong key was trusted" ); } #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn expired_key_is_not_authorized() { let root = tempfile::tempdir().unwrap(); let relay_url = spawn_relay().await; let ma = Keypair::generate(); let alice = Keypair::generate(); let registry_path = root.path().join("registry.json"); registry::save_registry( ®istry_path, &ma_registry( &ma, vec![member_entry( "alice.frx.example", &alice, "source", 0, Some(now_ts().saturating_sub(1)), )], 1, ), ) .unwrap(); let _bob = bob_with_docs( root.path(), &relay_url, ®istry_path.display().to_string(), &ma.public_hex(), ) .await; let http = client(); let denied = responses_for(&http, &relay_url, &alice, "alice.frx.example", "rust", 400).await; assert!(denied.is_empty(), "expired key was answered"); } #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn fail_static_uses_last_validated_snapshot() { let root = tempfile::tempdir().unwrap(); let relay_url = spawn_relay().await; let ma = Keypair::generate(); let alice = Keypair::generate(); let snapshot = ma_registry( &ma, vec![member_entry("alice.frx.example", &alice, "source", 0, None)], 1, ); let cached_dir = root.path().join("bob-cached"); let config = config_with_registry( &cached_dir, "bob", &relay_url, "http://127.0.0.1:1/registry.json", &ma.public_hex(), ); fs::create_dir_all(config.data_dir()).unwrap(); registry::save_registry(&config.registry_cache_path(), &snapshot).unwrap(); let docs = root.path().join("bob-docs"); fs::create_dir_all(&docs).unwrap(); fs::write(docs.join("doc.txt"), "registry rust document").unwrap(); { let index = LocalIndex::open(&config.index_dir()).unwrap(); index .add_collection(&collection("docs", &docs, true, EXPOSURE_FULL)) .unwrap(); } let _bob = Node::start(config.clone()).await.unwrap(); let http = client(); let accepted = responses_for(&http, &relay_url, &alice, "alice.frx.example", "rust", 700).await; assert_eq!( accepted.len(), 1, "cached snapshot should keep last-known-good membership" ); drop(_bob); let fresh_dir = root.path().join("bob-fresh"); let fresh = config_with_registry( &fresh_dir, "bob2", &relay_url, "http://127.0.0.1:1/registry.json", &ma.public_hex(), ); { let index = LocalIndex::open(&fresh.index_dir()).unwrap(); index .add_collection(&collection("docs", &docs, true, EXPOSURE_FULL)) .unwrap(); } let _bob2 = Node::start(fresh).await.unwrap(); let denied = responses_for(&http, &relay_url, &alice, "alice.frx.example", "rust", 400).await; assert!( denied.is_empty(), "unreachable registry without cache must close, not open" ); } #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn registry_can_be_served_over_http() { let root = tempfile::tempdir().unwrap(); let relay_url = spawn_relay().await; let ma = Keypair::generate(); let alice = Keypair::generate(); let registry_dir = root.path().join("ma"); fs::create_dir_all(®istry_dir).unwrap(); registry::save_registry( ®istry_dir.join("registry.json"), &ma_registry( &ma, vec![member_entry("alice.frx.example", &alice, "source", 0, None)], 1, ), ) .unwrap(); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let state: PathBuf = registry_dir.clone(); let handler_state = state.clone(); let app = Router::new() .route( "/registry.json", get(move || { let state = handler_state.clone(); async move { Json( registry::load_registry(&state.join("registry.json")) .expect("registry file"), ) } }), ) .with_state(state); tokio::spawn(async move { let _ = axum::serve(listener, app).await; }); let registry_url = format!("http://{addr}/registry.json"); let _bob = bob_with_docs(root.path(), &relay_url, ®istry_url, &ma.public_hex()).await; let http = client(); let accepted = responses_for(&http, &relay_url, &alice, "alice.frx.example", "rust", 900).await; assert_eq!( accepted.len(), 1, "http-served registry did not authorize member" ); }