Files
frxd/tests/registry.rs
T

357 lines
11 KiB
Rust

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<u64>,
) -> 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<RegistryMember>, 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<Value> {
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(
&registry_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,
&registry_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(&registry_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(
&registry_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(
&registry_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,
&registry_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(
&registry_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,
&registry_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(&registry_dir).unwrap();
registry::save_registry(
&registry_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, &registry_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"
);
}