Add Phase 1 frxd implementation with conformance test suite
This commit is contained in:
+250
@@ -0,0 +1,250 @@
|
||||
mod common;
|
||||
|
||||
use std::fs;
|
||||
use std::io::{BufRead, BufReader, Read};
|
||||
use std::path::Path;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
fn frxd() -> Command {
|
||||
Command::new(env!("CARGO_BIN_EXE_frxd"))
|
||||
}
|
||||
|
||||
fn frx() -> Command {
|
||||
Command::new(env!("CARGO_BIN_EXE_frx"))
|
||||
}
|
||||
|
||||
fn init_args(
|
||||
config: &Path,
|
||||
name: &str,
|
||||
listen_port: u16,
|
||||
relay_port: u16,
|
||||
data: &Path,
|
||||
) -> Vec<String> {
|
||||
vec![
|
||||
"--config".to_string(),
|
||||
config.display().to_string(),
|
||||
"init".to_string(),
|
||||
"--name".to_string(),
|
||||
name.to_string(),
|
||||
"--listen".to_string(),
|
||||
format!("127.0.0.1:{listen_port}"),
|
||||
"--relay".to_string(),
|
||||
format!("http://127.0.0.1:{relay_port}"),
|
||||
"--data-dir".to_string(),
|
||||
data.display().to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
fn add_args(config: &Path, docs: &Path, name: &str, shared: bool) -> Vec<String> {
|
||||
let mut args = vec![
|
||||
"--config".to_string(),
|
||||
config.display().to_string(),
|
||||
"add".to_string(),
|
||||
docs.display().to_string(),
|
||||
"--name".to_string(),
|
||||
name.to_string(),
|
||||
"--exposure".to_string(),
|
||||
"full".to_string(),
|
||||
];
|
||||
if shared {
|
||||
args.push("--shared".to_string());
|
||||
}
|
||||
args
|
||||
}
|
||||
|
||||
fn run_ok(args: &[String]) -> String {
|
||||
let output = frxd().args(args).output().unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"command failed: {}\n{}",
|
||||
args.join(" "),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
String::from_utf8_lossy(&output.stdout).to_string()
|
||||
}
|
||||
|
||||
struct Service {
|
||||
child: Child,
|
||||
}
|
||||
|
||||
impl Drop for Service {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_service(args: &[String], needle: &str) -> Service {
|
||||
let mut child = frxd()
|
||||
.args(args)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let reader = BufReader::new(child.stdout.take().unwrap());
|
||||
let mut reader = reader;
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
let read = reader.read_line(&mut line).unwrap();
|
||||
if read == 0 {
|
||||
panic!("service exited before printing '{needle}'");
|
||||
}
|
||||
if line.contains(needle) {
|
||||
break;
|
||||
}
|
||||
assert!(Instant::now() < deadline, "timeout waiting for '{needle}'");
|
||||
}
|
||||
std::thread::spawn(move || {
|
||||
let mut sink = Vec::new();
|
||||
let _ = reader.read_to_end(&mut sink);
|
||||
});
|
||||
Service { child }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_init_add_search_status() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let config = root.path().join("frxd.toml");
|
||||
let data = root.path().join("data");
|
||||
let docs = root.path().join("docs");
|
||||
fs::create_dir_all(&docs).unwrap();
|
||||
fs::write(docs.join("note.txt"), "cli smoke rust document").unwrap();
|
||||
|
||||
let config_arg = config.display().to_string();
|
||||
let stdout = run_ok(&init_args(&config, "alice", 0, 1, &data));
|
||||
assert!(stdout.contains("pubkey"));
|
||||
assert!(config.exists());
|
||||
let key = data.join("key.hex");
|
||||
assert!(key.exists());
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = fs::metadata(&key).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o600, "key file must not be world readable");
|
||||
}
|
||||
|
||||
let stdout = run_ok(&add_args(&config, &docs, "docs", true));
|
||||
assert!(stdout.contains("indexed 1 file(s)"));
|
||||
|
||||
let stdout = run_ok(&[
|
||||
"--config".to_string(),
|
||||
config_arg.clone(),
|
||||
"search".to_string(),
|
||||
"rust".to_string(),
|
||||
]);
|
||||
assert!(stdout.contains("cli smoke rust document"));
|
||||
|
||||
let output = frx()
|
||||
.args(["--config", &config_arg, "search", "rust"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success());
|
||||
assert!(String::from_utf8_lossy(&output.stdout).contains("cli smoke rust document"));
|
||||
|
||||
let stdout = run_ok(&["--config".to_string(), config_arg, "status".to_string()]);
|
||||
assert!(stdout.contains("node not running"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_init_refuses_overwrite_without_force() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let config = root.path().join("frxd.toml");
|
||||
let data = root.path().join("data");
|
||||
let args = init_args(&config, "alice", 0, 1, &data);
|
||||
run_ok(&args);
|
||||
|
||||
let output = frxd().args(&args).output().unwrap();
|
||||
assert!(!output.status.success());
|
||||
assert!(String::from_utf8_lossy(&output.stderr).contains("already exists"));
|
||||
|
||||
let mut forced = args.clone();
|
||||
forced.push("--force".to_string());
|
||||
run_ok(&forced);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_full_network_pipeline() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let relay_port = common::free_port();
|
||||
let relay_args = vec![
|
||||
"relay".to_string(),
|
||||
"--listen".to_string(),
|
||||
format!("127.0.0.1:{relay_port}"),
|
||||
];
|
||||
let _relay = spawn_service(&relay_args, "relay listening");
|
||||
|
||||
let bob_port = common::free_port();
|
||||
let alice_port = common::free_port();
|
||||
let bob_config = root.path().join("bob.toml");
|
||||
let alice_config = root.path().join("alice.toml");
|
||||
let bob_docs = root.path().join("bob-docs");
|
||||
let alice_docs = root.path().join("alice-docs");
|
||||
fs::create_dir_all(&bob_docs).unwrap();
|
||||
fs::create_dir_all(&alice_docs).unwrap();
|
||||
fs::write(
|
||||
bob_docs.join("tantivy.md"),
|
||||
"# Tantivy BM25\nTantivy indexes rust documents.",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(alice_docs.join("private.txt"), "alice private rust note").unwrap();
|
||||
|
||||
run_ok(&init_args(
|
||||
&bob_config,
|
||||
"bob",
|
||||
bob_port,
|
||||
relay_port,
|
||||
&root.path().join("bob-data"),
|
||||
));
|
||||
run_ok(&init_args(
|
||||
&alice_config,
|
||||
"alice",
|
||||
alice_port,
|
||||
relay_port,
|
||||
&root.path().join("alice-data"),
|
||||
));
|
||||
run_ok(&add_args(&bob_config, &bob_docs, "shared", true));
|
||||
run_ok(&add_args(&alice_config, &alice_docs, "mine", false));
|
||||
|
||||
let bob_serve = vec![
|
||||
"--config".to_string(),
|
||||
bob_config.display().to_string(),
|
||||
"serve".to_string(),
|
||||
];
|
||||
let alice_serve = vec![
|
||||
"--config".to_string(),
|
||||
alice_config.display().to_string(),
|
||||
"serve".to_string(),
|
||||
];
|
||||
let _bob = spawn_service(&bob_serve, "frxd listening");
|
||||
let _alice = spawn_service(&alice_serve, "frxd listening");
|
||||
std::thread::sleep(Duration::from_millis(400));
|
||||
|
||||
let alice_config_arg = alice_config.display().to_string();
|
||||
let stdout = run_ok(&[
|
||||
"--config".to_string(),
|
||||
alice_config_arg.clone(),
|
||||
"query".to_string(),
|
||||
"tantivy".to_string(),
|
||||
]);
|
||||
assert!(stdout.contains("Tantivy BM25"), "{stdout}");
|
||||
assert!(stdout.contains("remote: 1 response(s)"), "{stdout}");
|
||||
|
||||
let stdout = run_ok(&[
|
||||
"--config".to_string(),
|
||||
alice_config_arg.clone(),
|
||||
"query".to_string(),
|
||||
"rust".to_string(),
|
||||
"--local-only".to_string(),
|
||||
]);
|
||||
assert!(stdout.contains("alice private rust note"), "{stdout}");
|
||||
assert!(stdout.contains("remote: 0 response(s)"), "{stdout}");
|
||||
|
||||
let output = frx()
|
||||
.args(["--config", &alice_config_arg, "query", "tantivy"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success());
|
||||
assert!(String::from_utf8_lossy(&output.stdout).contains("Tantivy BM25"));
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
#![allow(dead_code)]
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use frxd::config::{Config, IndexSection, NodeSection, QuerySection};
|
||||
use frxd::crypto::Keypair;
|
||||
use frxd::index::Collection;
|
||||
use frxd::message::{Envelope, QueryBody, TYPE_QUERY};
|
||||
use frxd::relay;
|
||||
use serde_json::Value;
|
||||
|
||||
pub fn config_for(dir: &Path, name: &str, relay_url: &str) -> Config {
|
||||
let config = Config {
|
||||
node: NodeSection {
|
||||
name: name.to_string(),
|
||||
listen: "127.0.0.1:0".to_string(),
|
||||
relays: vec![relay_url.to_string()],
|
||||
trusted_keys: Vec::new(),
|
||||
responder: true,
|
||||
},
|
||||
query: QuerySection {
|
||||
max_results: 5,
|
||||
timeout_ms: 700,
|
||||
},
|
||||
index: IndexSection {
|
||||
data_dir: dir.join("data").display().to_string(),
|
||||
},
|
||||
};
|
||||
config.save_key(&Keypair::generate()).unwrap();
|
||||
config
|
||||
}
|
||||
|
||||
pub fn collection(name: &str, path: &Path, shared: bool, exposure: &str) -> Collection {
|
||||
Collection {
|
||||
name: name.to_string(),
|
||||
path: path.display().to_string(),
|
||||
shared,
|
||||
exposure: exposure.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn spawn_relay() -> String {
|
||||
spawn_relay_with_capacity(256).await
|
||||
}
|
||||
|
||||
pub async fn spawn_relay_with_capacity(capacity: usize) -> String {
|
||||
let (listener, addr) = relay::bind("127.0.0.1:0").await.unwrap();
|
||||
tokio::spawn(async move {
|
||||
let _ = relay::run(listener, capacity).await;
|
||||
});
|
||||
format!("http://{addr}")
|
||||
}
|
||||
|
||||
pub fn free_port() -> u16 {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
listener.local_addr().unwrap().port()
|
||||
}
|
||||
|
||||
pub fn query_envelope(key: &Keypair, text: &str, max_results: usize) -> Envelope {
|
||||
let body = QueryBody::new(text, max_results);
|
||||
Envelope::new(key, TYPE_QUERY, serde_json::to_value(&body).unwrap())
|
||||
}
|
||||
|
||||
pub async fn register(client: &reqwest::Client, relay_url: &str, member: &str) {
|
||||
poll(client, relay_url, member, 30).await;
|
||||
}
|
||||
|
||||
pub async fn poll_messages(
|
||||
client: &reqwest::Client,
|
||||
relay_url: &str,
|
||||
member: &str,
|
||||
timeout_ms: u64,
|
||||
) -> Vec<Value> {
|
||||
let response = poll(client, relay_url, member, timeout_ms).await;
|
||||
if response.status() == reqwest::StatusCode::NO_CONTENT {
|
||||
return Vec::new();
|
||||
}
|
||||
let payload: Value = response.json().await.unwrap();
|
||||
payload
|
||||
.get("messages")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn messages_of_type(messages: &[Value], msg_type: &str) -> Vec<Value> {
|
||||
messages
|
||||
.iter()
|
||||
.filter(|m| m.get("type").and_then(Value::as_str) == Some(msg_type))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn client() -> reqwest::Client {
|
||||
reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.build()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub async fn ask(
|
||||
client: &reqwest::Client,
|
||||
addr: &str,
|
||||
text: &str,
|
||||
max: usize,
|
||||
) -> (reqwest::StatusCode, String, Value) {
|
||||
let response = client
|
||||
.post(format!("http://{addr}/v1/local/query"))
|
||||
.json(&serde_json::json!({
|
||||
"text": text,
|
||||
"max_results": max,
|
||||
"timeout_ms": 700,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let status = response.status();
|
||||
let raw = response.text().await.unwrap();
|
||||
let value: Value = serde_json::from_str(&raw).unwrap();
|
||||
(status, raw, value)
|
||||
}
|
||||
|
||||
pub async fn publish(
|
||||
client: &reqwest::Client,
|
||||
relay_url: &str,
|
||||
envelope: &Envelope,
|
||||
) -> reqwest::Response {
|
||||
client
|
||||
.post(format!("{relay_url}/v1/publish"))
|
||||
.json(envelope)
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub async fn unicast(
|
||||
client: &reqwest::Client,
|
||||
relay_url: &str,
|
||||
to: &str,
|
||||
envelope: &Envelope,
|
||||
) -> reqwest::Response {
|
||||
client
|
||||
.post(format!("{relay_url}/v1/unicast?to={to}"))
|
||||
.json(envelope)
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub async fn poll(
|
||||
client: &reqwest::Client,
|
||||
relay_url: &str,
|
||||
member: &str,
|
||||
timeout_ms: u64,
|
||||
) -> reqwest::Response {
|
||||
client
|
||||
.get(format!(
|
||||
"{relay_url}/v1/poll?member={member}&timeout_ms={timeout_ms}"
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
mod common;
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use common::{
|
||||
ask, client, collection, config_for, poll_messages, publish, query_envelope, register,
|
||||
spawn_relay,
|
||||
};
|
||||
use frxd::crypto::Keypair;
|
||||
use frxd::index::LocalIndex;
|
||||
use frxd::message::{EXPOSURE_FULL, TYPE_RESPONSE};
|
||||
use frxd::node::{self, Node, NodeHandle};
|
||||
use serde_json::Value;
|
||||
|
||||
fn corpus_dir(root: &Path, name: &str, files: &[(&str, &str)]) -> std::path::PathBuf {
|
||||
let dir = root.join(format!("{name}-docs"));
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
for (file, text) in files {
|
||||
fs::write(dir.join(file), text).unwrap();
|
||||
}
|
||||
dir
|
||||
}
|
||||
|
||||
async fn start_node_with_corpus(
|
||||
root: &Path,
|
||||
name: &str,
|
||||
relays: Vec<String>,
|
||||
files: &[(&str, &str)],
|
||||
) -> NodeHandle {
|
||||
let docs = corpus_dir(root, name, files);
|
||||
let mut config = config_for(&root.join(name), name, relays.first().unwrap());
|
||||
config.node.relays = relays;
|
||||
{
|
||||
let index = LocalIndex::open(&config.index_dir()).unwrap();
|
||||
index
|
||||
.add_collection(&collection("docs", &docs, true, EXPOSURE_FULL))
|
||||
.unwrap();
|
||||
}
|
||||
Node::start(config).await.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn concurrent_queries_each_get_their_response() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let relay_url = spawn_relay().await;
|
||||
let _bob = start_node_with_corpus(
|
||||
root.path(),
|
||||
"bob",
|
||||
vec![relay_url.clone()],
|
||||
&[("doc.txt", "concurrent rust document")],
|
||||
)
|
||||
.await;
|
||||
let alice = start_node_with_corpus(
|
||||
root.path(),
|
||||
"alice",
|
||||
vec![relay_url.clone()],
|
||||
&[("mine.txt", "alice notes")],
|
||||
)
|
||||
.await;
|
||||
|
||||
let http = client();
|
||||
let addr = alice.addr.to_string();
|
||||
let mut tasks = Vec::new();
|
||||
for _ in 0..4 {
|
||||
let http = http.clone();
|
||||
let addr = addr.clone();
|
||||
tasks.push(tokio::spawn(
|
||||
async move { ask(&http, &addr, "rust", 5).await },
|
||||
));
|
||||
}
|
||||
let mut qids = BTreeSet::new();
|
||||
for task in tasks {
|
||||
let (_status, _raw, value) = task.await.unwrap();
|
||||
let qid = value
|
||||
.get("qid")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap()
|
||||
.to_string();
|
||||
qids.insert(qid);
|
||||
let responses = value.get("responses").and_then(Value::as_array).unwrap();
|
||||
assert_eq!(responses.len(), 1, "query lost its response: {value}");
|
||||
}
|
||||
assert_eq!(qids.len(), 4);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn multi_relay_deduplicates_and_responds_once() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let relay_one = spawn_relay().await;
|
||||
let relay_two = spawn_relay().await;
|
||||
let _bob = start_node_with_corpus(
|
||||
root.path(),
|
||||
"bob",
|
||||
vec![relay_one.clone(), relay_two.clone()],
|
||||
&[("doc.txt", "dual relay rust document")],
|
||||
)
|
||||
.await;
|
||||
|
||||
let http = client();
|
||||
let alice = Keypair::generate();
|
||||
register(&http, &relay_one, &alice.public_hex()).await;
|
||||
register(&http, &relay_two, &alice.public_hex()).await;
|
||||
let envelope = query_envelope(&alice, "rust", 5);
|
||||
assert!(
|
||||
publish(&http, &relay_one, &envelope)
|
||||
.await
|
||||
.status()
|
||||
.is_success()
|
||||
);
|
||||
assert!(
|
||||
publish(&http, &relay_two, &envelope)
|
||||
.await
|
||||
.status()
|
||||
.is_success()
|
||||
);
|
||||
|
||||
let deadline = Instant::now() + Duration::from_millis(1200);
|
||||
let mut responses = Vec::new();
|
||||
while responses.is_empty() && Instant::now() < deadline {
|
||||
for relay in [&relay_one, &relay_two] {
|
||||
let messages = poll_messages(&http, relay, &alice.public_hex(), 200).await;
|
||||
responses.extend(
|
||||
messages
|
||||
.iter()
|
||||
.filter(|m| m.get("type").and_then(Value::as_str) == Some(TYPE_RESPONSE))
|
||||
.cloned(),
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
responses.len(),
|
||||
1,
|
||||
"duplicate relay delivery was answered twice"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn index_persists_across_node_restart() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let docs = corpus_dir(
|
||||
root.path(),
|
||||
"bob",
|
||||
&[("doc.txt", "persistent rust document")],
|
||||
);
|
||||
let config = config_for(&root.path().join("bob"), "bob", "http://127.0.0.1:1");
|
||||
{
|
||||
let index = LocalIndex::open(&config.index_dir()).unwrap();
|
||||
index
|
||||
.add_collection(&collection("docs", &docs, true, EXPOSURE_FULL))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let first = Node::start(config.clone()).await.unwrap();
|
||||
let outcome = node::control_query(
|
||||
&format!("http://{}", first.addr),
|
||||
"rust",
|
||||
Some(5),
|
||||
Some(100),
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
outcome.pointer("/local/total").and_then(Value::as_u64),
|
||||
Some(1)
|
||||
);
|
||||
drop(first);
|
||||
|
||||
let second = Node::start(config).await.unwrap();
|
||||
let outcome = node::control_query(
|
||||
&format!("http://{}", second.addr),
|
||||
"rust",
|
||||
Some(5),
|
||||
Some(100),
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
outcome.pointer("/local/total").and_then(Value::as_u64),
|
||||
Some(1)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn poll_returns_queued_batch_in_one_call() {
|
||||
let relay_url = spawn_relay().await;
|
||||
let http = client();
|
||||
let member = Keypair::generate();
|
||||
register(&http, &relay_url, &member.public_hex()).await;
|
||||
for index in 0..3 {
|
||||
let envelope = query_envelope(&member, &format!("query {index}"), 5);
|
||||
assert!(
|
||||
publish(&http, &relay_url, &envelope)
|
||||
.await
|
||||
.status()
|
||||
.is_success()
|
||||
);
|
||||
}
|
||||
let messages = poll_messages(&http, &relay_url, &member.public_hex(), 300).await;
|
||||
assert_eq!(messages.len(), 3);
|
||||
assert!(
|
||||
poll_messages(&http, &relay_url, &member.public_hex(), 50)
|
||||
.await
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,924 @@
|
||||
mod common;
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use common::{
|
||||
ask, client, collection, config_for, messages_of_type, poll, poll_messages, publish,
|
||||
query_envelope, register, spawn_relay, spawn_relay_with_capacity, unicast,
|
||||
};
|
||||
use frxd::crypto::Keypair;
|
||||
use frxd::index::LocalIndex;
|
||||
use frxd::message::{
|
||||
EXPOSURE_FULL, EXPOSURE_METADATA, Envelope, QueryBody, TYPE_AGGREGATE, TYPE_QUERY,
|
||||
TYPE_RESPONSE,
|
||||
};
|
||||
use frxd::node::{Node, NodeHandle};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn keys(value: &Value) -> BTreeSet<String> {
|
||||
value.as_object().expect("object").keys().cloned().collect()
|
||||
}
|
||||
|
||||
async fn status(http: &reqwest::Client, addr: &str) -> Value {
|
||||
http.get(format!("http://{addr}/v1/local/status"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn corpus_dir(root: &Path, name: &str, files: &[(&str, &str)]) -> PathBuf {
|
||||
let dir = root.join(format!("{name}-docs"));
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
for (file, text) in files {
|
||||
fs::write(dir.join(file), text).unwrap();
|
||||
}
|
||||
dir
|
||||
}
|
||||
|
||||
async fn start_node_with_corpus(
|
||||
root: &Path,
|
||||
name: &str,
|
||||
relay_url: &str,
|
||||
shared: bool,
|
||||
exposure: &str,
|
||||
files: &[(&str, &str)],
|
||||
) -> NodeHandle {
|
||||
let docs = corpus_dir(root, name, files);
|
||||
let config = config_for(&root.join(name), name, relay_url);
|
||||
{
|
||||
let index = LocalIndex::open(&config.index_dir()).unwrap();
|
||||
index
|
||||
.add_collection(&collection("docs", &docs, shared, exposure))
|
||||
.unwrap();
|
||||
}
|
||||
Node::start(config).await.unwrap()
|
||||
}
|
||||
|
||||
async fn collect_responses(
|
||||
http: &reqwest::Client,
|
||||
relay_url: &str,
|
||||
member: &str,
|
||||
timeout_ms: u64,
|
||||
) -> Vec<Value> {
|
||||
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, member, 200).await;
|
||||
responses.extend(messages_of_type(&messages, TYPE_RESPONSE));
|
||||
if !responses.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
responses
|
||||
}
|
||||
|
||||
async fn raw_query(
|
||||
http: &reqwest::Client,
|
||||
relay_url: &str,
|
||||
asker: &Keypair,
|
||||
text: &str,
|
||||
timeout_ms: u64,
|
||||
) -> Vec<Value> {
|
||||
register(http, relay_url, &asker.public_hex()).await;
|
||||
let envelope = query_envelope(asker, text, 5);
|
||||
assert!(
|
||||
publish(http, relay_url, &envelope)
|
||||
.await
|
||||
.status()
|
||||
.is_success()
|
||||
);
|
||||
collect_responses(http, relay_url, &asker.public_hex(), timeout_ms).await
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelope_field_set_is_fixed() {
|
||||
let key = Keypair::generate();
|
||||
let envelope = Envelope::new(&key, TYPE_QUERY, json!({"qid": "q", "text": "t"}));
|
||||
let value = serde_json::to_value(&envelope).unwrap();
|
||||
assert_eq!(
|
||||
keys(&value),
|
||||
BTreeSet::from([
|
||||
"body".to_string(),
|
||||
"from".to_string(),
|
||||
"nonce".to_string(),
|
||||
"sig".to_string(),
|
||||
"ts".to_string(),
|
||||
"type".to_string(),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn ordering_travels_scores_dont() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let relay_url = spawn_relay().await;
|
||||
let _bob = start_node_with_corpus(
|
||||
root.path(),
|
||||
"bob",
|
||||
&relay_url,
|
||||
true,
|
||||
EXPOSURE_FULL,
|
||||
&[
|
||||
("strong.txt", "rust rust rust rust search engine"),
|
||||
("weak.txt", "rust notes"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let alice = Keypair::generate();
|
||||
let http = client();
|
||||
let responses = raw_query(&http, &relay_url, &alice, "rust", 700).await;
|
||||
assert_eq!(responses.len(), 1);
|
||||
let results = responses[0]
|
||||
.pointer("/body/results")
|
||||
.and_then(Value::as_array)
|
||||
.unwrap();
|
||||
assert_eq!(results.len(), 2);
|
||||
let first_url = results[0].get("url").and_then(Value::as_str).unwrap();
|
||||
assert!(
|
||||
first_url.contains("strong"),
|
||||
"best match should lead the ordering: {first_url}"
|
||||
);
|
||||
for result in results {
|
||||
assert!(!keys(result).contains("score"));
|
||||
assert!(!keys(result).contains("rank"));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn metadata_exposure_withholds_content() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let relay_url = spawn_relay().await;
|
||||
let _bob = start_node_with_corpus(
|
||||
root.path(),
|
||||
"bob",
|
||||
&relay_url,
|
||||
true,
|
||||
EXPOSURE_METADATA,
|
||||
&[("corpus.txt", "enrichment corpus about rust")],
|
||||
)
|
||||
.await;
|
||||
let alice = Keypair::generate();
|
||||
let http = client();
|
||||
let responses = raw_query(&http, &relay_url, &alice, "rust", 700).await;
|
||||
assert_eq!(responses.len(), 1);
|
||||
let result = &responses[0]
|
||||
.pointer("/body/results")
|
||||
.and_then(Value::as_array)
|
||||
.unwrap()[0];
|
||||
assert_eq!(
|
||||
result.get("exposure").and_then(Value::as_str),
|
||||
Some(EXPOSURE_METADATA)
|
||||
);
|
||||
assert!(result.get("content").unwrap().is_null());
|
||||
assert!(
|
||||
!result
|
||||
.get("summary")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn full_exposure_carries_content() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let relay_url = spawn_relay().await;
|
||||
let _bob = start_node_with_corpus(
|
||||
root.path(),
|
||||
"bob",
|
||||
&relay_url,
|
||||
true,
|
||||
EXPOSURE_FULL,
|
||||
&[("corpus.txt", "full exposure rust content body")],
|
||||
)
|
||||
.await;
|
||||
let alice = Keypair::generate();
|
||||
let http = client();
|
||||
let responses = raw_query(&http, &relay_url, &alice, "rust", 700).await;
|
||||
let result = &responses[0]
|
||||
.pointer("/body/results")
|
||||
.and_then(Value::as_array)
|
||||
.unwrap()[0];
|
||||
assert_eq!(
|
||||
result.get("exposure").and_then(Value::as_str),
|
||||
Some(EXPOSURE_FULL)
|
||||
);
|
||||
assert!(
|
||||
result
|
||||
.get("content")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap()
|
||||
.contains("full exposure rust content body")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn private_collections_are_silent_egress() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let relay_url = spawn_relay().await;
|
||||
let _bob = start_node_with_corpus(
|
||||
root.path(),
|
||||
"bob",
|
||||
&relay_url,
|
||||
false,
|
||||
EXPOSURE_FULL,
|
||||
&[("private.txt", "rust private data")],
|
||||
)
|
||||
.await;
|
||||
let alice = Keypair::generate();
|
||||
let http = client();
|
||||
let responses = raw_query(&http, &relay_url, &alice, "rust", 500).await;
|
||||
assert!(responses.is_empty(), "private collection answered");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn one_result_is_a_conformant_response() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let relay_url = spawn_relay().await;
|
||||
let _bob = start_node_with_corpus(
|
||||
root.path(),
|
||||
"bob",
|
||||
&relay_url,
|
||||
true,
|
||||
EXPOSURE_FULL,
|
||||
&[("only.txt", "solitary rust document")],
|
||||
)
|
||||
.await;
|
||||
let alice = Keypair::generate();
|
||||
let http = client();
|
||||
let responses = raw_query(&http, &relay_url, &alice, "solitary", 700).await;
|
||||
assert_eq!(responses.len(), 1);
|
||||
assert_eq!(
|
||||
responses[0]
|
||||
.pointer("/body/results")
|
||||
.and_then(Value::as_array)
|
||||
.unwrap()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
responses[0]
|
||||
.pointer("/body/truncated")
|
||||
.and_then(Value::as_bool),
|
||||
Some(false)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn empty_query_earns_silence() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let relay_url = spawn_relay().await;
|
||||
let bob = start_node_with_corpus(
|
||||
root.path(),
|
||||
"bob",
|
||||
&relay_url,
|
||||
true,
|
||||
EXPOSURE_FULL,
|
||||
&[("doc.txt", "rust content")],
|
||||
)
|
||||
.await;
|
||||
let alice = start_node_with_corpus(
|
||||
root.path(),
|
||||
"alice",
|
||||
&relay_url,
|
||||
false,
|
||||
EXPOSURE_FULL,
|
||||
&[("mine.txt", "alice rust")],
|
||||
)
|
||||
.await;
|
||||
let response = client()
|
||||
.post(format!("http://{}/v1/local/query", alice.addr))
|
||||
.json(&json!({"text": "", "max_results": 5, "timeout_ms": 500}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
response
|
||||
.get("responses")
|
||||
.and_then(Value::as_array)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert!(bob.node.doc_count() > 0);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn entities_are_optional_hints() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let relay_url = spawn_relay().await;
|
||||
let _bob = start_node_with_corpus(
|
||||
root.path(),
|
||||
"bob",
|
||||
&relay_url,
|
||||
true,
|
||||
EXPOSURE_FULL,
|
||||
&[("doc.txt", "entity tagged rust document")],
|
||||
)
|
||||
.await;
|
||||
let alice = Keypair::generate();
|
||||
let http = client();
|
||||
register(&http, &relay_url, &alice.public_hex()).await;
|
||||
let body = json!({
|
||||
"qid": "entities-test",
|
||||
"text": "rust",
|
||||
"entities": ["Q999999", "not-a-real-entity"],
|
||||
"budget": {"max_results": 5}
|
||||
});
|
||||
let envelope = Envelope::new(&alice, TYPE_QUERY, body);
|
||||
assert!(
|
||||
publish(&http, &relay_url, &envelope)
|
||||
.await
|
||||
.status()
|
||||
.is_success()
|
||||
);
|
||||
let responses = collect_responses(&http, &relay_url, &alice.public_hex(), 700).await;
|
||||
assert_eq!(responses.len(), 1);
|
||||
assert_eq!(
|
||||
responses[0].pointer("/body/qid").and_then(Value::as_str),
|
||||
Some("entities-test")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn trusted_keys_allowlist_filters_senders() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let relay_url = spawn_relay().await;
|
||||
let alice = Keypair::generate();
|
||||
let untrusted = Keypair::generate();
|
||||
|
||||
let docs = corpus_dir(root.path(), "bob", &[("doc.txt", "rust document")]);
|
||||
let mut config = config_for(&root.path().join("bob"), "bob", &relay_url);
|
||||
config.node.trusted_keys = vec![alice.public_hex()];
|
||||
{
|
||||
let index = LocalIndex::open(&config.index_dir()).unwrap();
|
||||
index
|
||||
.add_collection(&collection("docs", &docs, true, EXPOSURE_FULL))
|
||||
.unwrap();
|
||||
}
|
||||
let _bob = Node::start(config).await.unwrap();
|
||||
|
||||
let http = client();
|
||||
let trusted = raw_query(&http, &relay_url, &alice, "rust", 700).await;
|
||||
assert_eq!(trusted.len(), 1, "trusted member got no response");
|
||||
|
||||
register(&http, &relay_url, &untrusted.public_hex()).await;
|
||||
let envelope = query_envelope(&untrusted, "rust", 5);
|
||||
assert!(
|
||||
publish(&http, &relay_url, &envelope)
|
||||
.await
|
||||
.status()
|
||||
.is_success()
|
||||
);
|
||||
let denied = collect_responses(&http, &relay_url, &untrusted.public_hex(), 400).await;
|
||||
assert!(denied.is_empty(), "untrusted member received a response");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn backpressure_is_visible_and_recoverable() {
|
||||
let relay_url = spawn_relay_with_capacity(1).await;
|
||||
let http = client();
|
||||
let member = Keypair::generate();
|
||||
let publisher = Keypair::generate();
|
||||
register(&http, &relay_url, &member.public_hex()).await;
|
||||
|
||||
let first = query_envelope(&publisher, "one", 5);
|
||||
assert_eq!(
|
||||
publish(&http, &relay_url, &first).await.status(),
|
||||
reqwest::StatusCode::ACCEPTED
|
||||
);
|
||||
let second = query_envelope(&publisher, "two", 5);
|
||||
let response = publish(&http, &relay_url, &second).await;
|
||||
assert_eq!(response.status(), reqwest::StatusCode::TOO_MANY_REQUESTS);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("retry-after")
|
||||
.and_then(|v| v.to_str().ok()),
|
||||
Some("1")
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
poll_messages(&http, &relay_url, &member.public_hex(), 300)
|
||||
.await
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
poll_messages(&http, &relay_url, &publisher.public_hex(), 300)
|
||||
.await
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
|
||||
let third = query_envelope(&publisher, "three", 5);
|
||||
assert_eq!(
|
||||
publish(&http, &relay_url, &third).await.status(),
|
||||
reqwest::StatusCode::ACCEPTED
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn unicast_is_need_to_know() {
|
||||
let relay_url = spawn_relay().await;
|
||||
let http = client();
|
||||
let alice = Keypair::generate();
|
||||
let bob = Keypair::generate();
|
||||
register(&http, &relay_url, &alice.public_hex()).await;
|
||||
register(&http, &relay_url, &bob.public_hex()).await;
|
||||
|
||||
let query = query_envelope(&alice, "rust", 5);
|
||||
assert!(
|
||||
publish(&http, &relay_url, &query)
|
||||
.await
|
||||
.status()
|
||||
.is_success()
|
||||
);
|
||||
|
||||
let response = Envelope::new(
|
||||
&bob,
|
||||
TYPE_RESPONSE,
|
||||
json!({"qid": "q1", "results": [], "truncated": false, "more_available": 0, "cursor": null}),
|
||||
);
|
||||
assert_eq!(
|
||||
unicast(&http, &relay_url, &alice.public_hex(), &response)
|
||||
.await
|
||||
.status(),
|
||||
reqwest::StatusCode::OK
|
||||
);
|
||||
|
||||
let alice_messages = poll_messages(&http, &relay_url, &alice.public_hex(), 300).await;
|
||||
assert_eq!(messages_of_type(&alice_messages, TYPE_RESPONSE).len(), 1);
|
||||
let bob_messages = poll_messages(&http, &relay_url, &bob.public_hex(), 300).await;
|
||||
assert!(
|
||||
messages_of_type(&bob_messages, TYPE_RESPONSE).is_empty(),
|
||||
"unicast response leaked to another member"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn unicast_to_unknown_member_is_visible() {
|
||||
let relay_url = spawn_relay().await;
|
||||
let http = client();
|
||||
let sender = Keypair::generate();
|
||||
let response = Envelope::new(
|
||||
&sender,
|
||||
TYPE_RESPONSE,
|
||||
json!({"qid": "q1", "results": [], "truncated": false, "more_available": 0, "cursor": null}),
|
||||
);
|
||||
let stranger = Keypair::generate().public_hex();
|
||||
assert_eq!(
|
||||
unicast(&http, &relay_url, &stranger, &response)
|
||||
.await
|
||||
.status(),
|
||||
reqwest::StatusCode::NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn tampered_envelopes_are_rejected() {
|
||||
let relay_url = spawn_relay().await;
|
||||
let http = client();
|
||||
let key = Keypair::generate();
|
||||
let mut envelope = query_envelope(&key, "rust", 5);
|
||||
envelope.body = json!({"qid": "q1", "text": "forged", "budget": {"max_results": 5}});
|
||||
let publish_response = publish(&http, &relay_url, &envelope).await;
|
||||
assert_eq!(publish_response.status(), reqwest::StatusCode::BAD_REQUEST);
|
||||
|
||||
let mut response = Envelope::new(
|
||||
&key,
|
||||
TYPE_RESPONSE,
|
||||
json!({"qid": "q1", "results": [], "truncated": false, "more_available": 0, "cursor": null}),
|
||||
);
|
||||
response.sig = "00".repeat(64);
|
||||
let unicast_response = unicast(&http, &relay_url, &key.public_hex(), &response).await;
|
||||
assert_eq!(unicast_response.status(), reqwest::StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn invalid_poll_member_is_rejected() {
|
||||
let relay_url = spawn_relay().await;
|
||||
let response = poll(&client(), &relay_url, "not-hex-at-all", 30).await;
|
||||
assert_eq!(response.status(), reqwest::StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn malformed_json_is_rejected() {
|
||||
let relay_url = spawn_relay().await;
|
||||
let http = client();
|
||||
let response = http
|
||||
.post(format!("{relay_url}/v1/publish"))
|
||||
.header("content-type", "application/json")
|
||||
.body("{not json")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), reqwest::StatusCode::BAD_REQUEST);
|
||||
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let config = config_for(&root.path().join("alice"), "alice", &relay_url);
|
||||
let node = Node::start(config).await.unwrap();
|
||||
let response = http
|
||||
.post(format!("http://{}/v1/local/query", node.addr))
|
||||
.header("content-type", "application/json")
|
||||
.body("{not json")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), reqwest::StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn node_does_not_answer_its_own_queries() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let relay_url = spawn_relay().await;
|
||||
let bob = start_node_with_corpus(
|
||||
root.path(),
|
||||
"bob",
|
||||
&relay_url,
|
||||
true,
|
||||
EXPOSURE_FULL,
|
||||
&[("doc.txt", "self query rust")],
|
||||
)
|
||||
.await;
|
||||
let (_status, _raw, value) = ask(&client(), &bob.addr.to_string(), "rust", 5).await;
|
||||
assert!(
|
||||
value
|
||||
.get("responses")
|
||||
.and_then(Value::as_array)
|
||||
.unwrap()
|
||||
.is_empty(),
|
||||
"node responded to itself: {value}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn ingress_happens_only_locally() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let relay_url = spawn_relay().await;
|
||||
let bob = start_node_with_corpus(
|
||||
root.path(),
|
||||
"bob",
|
||||
&relay_url,
|
||||
true,
|
||||
EXPOSURE_FULL,
|
||||
&[("doc.txt", "rust served by bob")],
|
||||
)
|
||||
.await;
|
||||
let alice = start_node_with_corpus(
|
||||
root.path(),
|
||||
"alice",
|
||||
&relay_url,
|
||||
false,
|
||||
EXPOSURE_FULL,
|
||||
&[("mine.txt", "alice rust notes")],
|
||||
)
|
||||
.await;
|
||||
let http = client();
|
||||
let bob_before = status(&http, &bob.addr.to_string()).await;
|
||||
let alice_before = status(&http, &alice.addr.to_string()).await;
|
||||
|
||||
let (_s, _r, value) = ask(&http, &alice.addr.to_string(), "rust", 5).await;
|
||||
assert_eq!(
|
||||
value
|
||||
.get("responses")
|
||||
.and_then(Value::as_array)
|
||||
.unwrap()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
|
||||
let bob_after = status(&http, &bob.addr.to_string()).await;
|
||||
let alice_after = status(&http, &alice.addr.to_string()).await;
|
||||
assert_eq!(bob_before.get("doc_count"), bob_after.get("doc_count"));
|
||||
assert_eq!(alice_before.get("doc_count"), alice_after.get("doc_count"));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn querier_and_responder_roles_coexist() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let relay_url = spawn_relay().await;
|
||||
let alice = start_node_with_corpus(
|
||||
root.path(),
|
||||
"alice",
|
||||
&relay_url,
|
||||
true,
|
||||
EXPOSURE_FULL,
|
||||
&[("a.txt", "alice rust document")],
|
||||
)
|
||||
.await;
|
||||
let bob = start_node_with_corpus(
|
||||
root.path(),
|
||||
"bob",
|
||||
&relay_url,
|
||||
true,
|
||||
EXPOSURE_FULL,
|
||||
&[("b.txt", "bob rust document")],
|
||||
)
|
||||
.await;
|
||||
|
||||
let http = client();
|
||||
let alice_addr = alice.addr.to_string();
|
||||
let bob_addr = bob.addr.to_string();
|
||||
let alice_ask = async { common::ask(&http, &alice_addr, "rust", 5).await };
|
||||
let bob_ask = async { common::ask(&http, &bob_addr, "rust", 5).await };
|
||||
let (alice_result, bob_result) = tokio::join!(alice_ask, bob_ask);
|
||||
|
||||
for (name, (_status, _raw, value)) in [("alice", alice_result), ("bob", bob_result)] {
|
||||
let responses = value.get("responses").and_then(Value::as_array).unwrap();
|
||||
assert_eq!(responses.len(), 1, "{name} got {value}");
|
||||
let member = responses[0].get("member").and_then(Value::as_str).unwrap();
|
||||
assert!(!member.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn budget_is_clamped_and_never_exceeded() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let relay_url = spawn_relay().await;
|
||||
let _bob = start_node_with_corpus(
|
||||
root.path(),
|
||||
"bob",
|
||||
&relay_url,
|
||||
true,
|
||||
EXPOSURE_FULL,
|
||||
&[
|
||||
("one.txt", "rust one"),
|
||||
("two.txt", "rust two"),
|
||||
("three.txt", "rust three"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let alice = start_node_with_corpus(
|
||||
root.path(),
|
||||
"alice",
|
||||
&relay_url,
|
||||
false,
|
||||
EXPOSURE_FULL,
|
||||
&[("mine.txt", "alice doc")],
|
||||
)
|
||||
.await;
|
||||
let http = client();
|
||||
|
||||
let (_s, _r, value) = ask(&http, &alice.addr.to_string(), "rust", 0).await;
|
||||
let responses = value.get("responses").and_then(Value::as_array).unwrap();
|
||||
assert_eq!(responses.len(), 1);
|
||||
assert_eq!(
|
||||
responses[0]
|
||||
.get("results")
|
||||
.and_then(Value::as_array)
|
||||
.unwrap()
|
||||
.len(),
|
||||
1,
|
||||
"zero budget must clamp to one, never send nothing or more"
|
||||
);
|
||||
|
||||
let (_s, _r, value) = ask(&http, &alice.addr.to_string(), "rust", 1000).await;
|
||||
let responses = value.get("responses").and_then(Value::as_array).unwrap();
|
||||
assert_eq!(
|
||||
responses[0]
|
||||
.get("results")
|
||||
.and_then(Value::as_array)
|
||||
.unwrap()
|
||||
.len(),
|
||||
3
|
||||
);
|
||||
assert_eq!(
|
||||
responses[0].get("truncated").and_then(Value::as_bool),
|
||||
Some(false)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn aggregates_are_unicast_only_and_ignored_by_nodes() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let relay_url = spawn_relay().await;
|
||||
let bob = start_node_with_corpus(
|
||||
root.path(),
|
||||
"bob",
|
||||
&relay_url,
|
||||
true,
|
||||
EXPOSURE_FULL,
|
||||
&[("doc.txt", "aggregate test rust")],
|
||||
)
|
||||
.await;
|
||||
let http = client();
|
||||
let sender = Keypair::generate();
|
||||
register(&http, &relay_url, &bob.pubkey).await;
|
||||
|
||||
let broadcast = Envelope::new(
|
||||
&sender,
|
||||
TYPE_AGGREGATE,
|
||||
json!({"period": "2026-03", "sent": 1, "passed": 0, "cited": 0}),
|
||||
);
|
||||
assert_eq!(
|
||||
publish(&http, &relay_url, &broadcast).await.status(),
|
||||
reqwest::StatusCode::BAD_REQUEST,
|
||||
"aggregates must not travel the broadcast channel"
|
||||
);
|
||||
|
||||
let unicast_envelope = Envelope::new(
|
||||
&sender,
|
||||
TYPE_AGGREGATE,
|
||||
json!({"period": "2026-03", "sent": 1, "passed": 0, "cited": 0}),
|
||||
);
|
||||
assert_eq!(
|
||||
unicast(&http, &relay_url, &bob.pubkey, &unicast_envelope)
|
||||
.await
|
||||
.status(),
|
||||
reqwest::StatusCode::OK
|
||||
);
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
let (_s, _r, value) = ask(&http, &bob.addr.to_string(), "ignored", 5).await;
|
||||
assert_eq!(
|
||||
value.pointer("/local/total").and_then(Value::as_u64),
|
||||
Some(0)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn node_survives_unreachable_relay() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let alice = start_node_with_corpus(
|
||||
root.path(),
|
||||
"alice",
|
||||
"http://127.0.0.1:1",
|
||||
false,
|
||||
EXPOSURE_FULL,
|
||||
&[("mine.txt", "offline rust note")],
|
||||
)
|
||||
.await;
|
||||
let (status_code, _raw, value) = ask(&client(), &alice.addr.to_string(), "rust", 5).await;
|
||||
assert!(status_code.is_success());
|
||||
assert_eq!(
|
||||
value.pointer("/local/total").and_then(Value::as_u64),
|
||||
Some(1)
|
||||
);
|
||||
assert!(
|
||||
value
|
||||
.get("responses")
|
||||
.and_then(Value::as_array)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn duplicate_delivery_is_answered_once() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let relay_url = spawn_relay().await;
|
||||
let _bob = start_node_with_corpus(
|
||||
root.path(),
|
||||
"bob",
|
||||
&relay_url,
|
||||
true,
|
||||
EXPOSURE_FULL,
|
||||
&[("doc.txt", "duplicate rust doc")],
|
||||
)
|
||||
.await;
|
||||
let alice = Keypair::generate();
|
||||
let http = client();
|
||||
register(&http, &relay_url, &alice.public_hex()).await;
|
||||
let envelope = query_envelope(&alice, "rust", 5);
|
||||
assert!(
|
||||
publish(&http, &relay_url, &envelope)
|
||||
.await
|
||||
.status()
|
||||
.is_success()
|
||||
);
|
||||
assert!(
|
||||
publish(&http, &relay_url, &envelope)
|
||||
.await
|
||||
.status()
|
||||
.is_success()
|
||||
);
|
||||
let responses = collect_responses(&http, &relay_url, &alice.public_hex(), 700).await;
|
||||
assert_eq!(
|
||||
responses.len(),
|
||||
1,
|
||||
"duplicate query produced duplicate responses"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn multiple_responders_merge_with_provenance() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let relay_url = spawn_relay().await;
|
||||
let _bob = start_node_with_corpus(
|
||||
root.path(),
|
||||
"bob",
|
||||
&relay_url,
|
||||
true,
|
||||
EXPOSURE_FULL,
|
||||
&[("bob.txt", "bob rust contribution")],
|
||||
)
|
||||
.await;
|
||||
let _carol = start_node_with_corpus(
|
||||
root.path(),
|
||||
"carol",
|
||||
&relay_url,
|
||||
true,
|
||||
EXPOSURE_FULL,
|
||||
&[("carol.txt", "carol rust contribution")],
|
||||
)
|
||||
.await;
|
||||
let alice = start_node_with_corpus(
|
||||
root.path(),
|
||||
"alice",
|
||||
&relay_url,
|
||||
false,
|
||||
EXPOSURE_FULL,
|
||||
&[("mine.txt", "alice rust local")],
|
||||
)
|
||||
.await;
|
||||
let (_s, _r, value) = ask(&client(), &alice.addr.to_string(), "rust", 5).await;
|
||||
let responses = value.get("responses").and_then(Value::as_array).unwrap();
|
||||
assert_eq!(responses.len(), 2, "expected both responders: {value}");
|
||||
let members: BTreeSet<&str> = responses
|
||||
.iter()
|
||||
.map(|r| r.get("member").and_then(Value::as_str).unwrap())
|
||||
.collect();
|
||||
assert_eq!(members.len(), 2);
|
||||
let merged = value.get("merged").and_then(Value::as_array).unwrap();
|
||||
let provenances: BTreeSet<&str> = merged
|
||||
.iter()
|
||||
.map(|m| m.get("provenance").and_then(Value::as_str).unwrap())
|
||||
.collect();
|
||||
assert!(provenances.contains("local"));
|
||||
assert_eq!(provenances.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_signing_bytes_are_stable() {
|
||||
let mut envelope = Envelope {
|
||||
msg_type: TYPE_QUERY.to_string(),
|
||||
from: "aa".repeat(32),
|
||||
ts: 1_700_000_000,
|
||||
nonce: "00112233445566778899aabbccddeeff".to_string(),
|
||||
body: json!({"text": "rust", "qid": "q1", "budget": {"max_results": 5}, "entities": []}),
|
||||
sig: String::new(),
|
||||
};
|
||||
let first = frxd::crypto::signing_bytes(&envelope);
|
||||
envelope.body =
|
||||
json!({"entities": [], "budget": {"max_results": 5}, "qid": "q1", "text": "rust"});
|
||||
let second = frxd::crypto::signing_bytes(&envelope);
|
||||
assert_eq!(first, second);
|
||||
assert_eq!(
|
||||
String::from_utf8(first).unwrap(),
|
||||
"FRX/0.3\nquery\naaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n1700000000\n00112233445566778899aabbccddeeff\n{\"budget\":{\"max_results\":5},\"entities\":[],\"qid\":\"q1\",\"text\":\"rust\"}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn query_body_without_entities_parses() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let relay_url = spawn_relay().await;
|
||||
let _bob = start_node_with_corpus(
|
||||
root.path(),
|
||||
"bob",
|
||||
&relay_url,
|
||||
true,
|
||||
EXPOSURE_FULL,
|
||||
&[("doc.txt", "minimal body rust")],
|
||||
)
|
||||
.await;
|
||||
let alice = Keypair::generate();
|
||||
let http = client();
|
||||
register(&http, &relay_url, &alice.public_hex()).await;
|
||||
let envelope = Envelope::new(
|
||||
&alice,
|
||||
TYPE_QUERY,
|
||||
json!({"qid": "min", "text": "rust", "budget": {"max_results": 5}}),
|
||||
);
|
||||
assert!(
|
||||
publish(&http, &relay_url, &envelope)
|
||||
.await
|
||||
.status()
|
||||
.is_success()
|
||||
);
|
||||
let responses = collect_responses(&http, &relay_url, &alice.public_hex(), 700).await;
|
||||
assert_eq!(responses.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_body_budget_is_the_only_quantity_knob() {
|
||||
let value = serde_json::to_value(QueryBody::new("rust", 5)).unwrap();
|
||||
assert_eq!(
|
||||
keys(&value),
|
||||
BTreeSet::from([
|
||||
"qid".to_string(),
|
||||
"text".to_string(),
|
||||
"entities".to_string(),
|
||||
"budget".to_string()
|
||||
])
|
||||
);
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
mod common;
|
||||
|
||||
use std::fs;
|
||||
|
||||
use common::{ask, client, collection, config_for, publish, spawn_relay};
|
||||
use frxd::index::LocalIndex;
|
||||
use frxd::message::{EXPOSURE_FULL, Envelope, TYPE_RESPONSE};
|
||||
use frxd::node::Node;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn two_node_broadcast_query_flow() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let relay_url = spawn_relay().await;
|
||||
|
||||
let shared_dir = root.path().join("bob/shared");
|
||||
let private_dir = root.path().join("bob/private");
|
||||
fs::create_dir_all(&shared_dir).unwrap();
|
||||
fs::create_dir_all(&private_dir).unwrap();
|
||||
fs::write(
|
||||
shared_dir.join("alpha.txt"),
|
||||
"Tantivy provides BM25 relevance scoring for rust search",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
shared_dir.join("beta.txt"),
|
||||
"Rust lifetimes and borrowing explained",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(private_dir.join("gamma.txt"), "rust secret launch codes").unwrap();
|
||||
|
||||
let bob_config = config_for(&root.path().join("bob"), "bob", &relay_url);
|
||||
{
|
||||
let index = LocalIndex::open(&bob_config.index_dir()).unwrap();
|
||||
index
|
||||
.add_collection(&collection("shared", &shared_dir, true, EXPOSURE_FULL))
|
||||
.unwrap();
|
||||
index
|
||||
.add_collection(&collection("private", &private_dir, false, "metadata"))
|
||||
.unwrap();
|
||||
}
|
||||
let _bob = Node::start(bob_config).await.unwrap();
|
||||
|
||||
let alice_dir = root.path().join("alice");
|
||||
let alice_local = root.path().join("alice_mine");
|
||||
fs::create_dir_all(&alice_local).unwrap();
|
||||
fs::write(alice_local.join("notes.txt"), "my rust notes").unwrap();
|
||||
let alice_config = config_for(&alice_dir, "alice", &relay_url);
|
||||
{
|
||||
let index = LocalIndex::open(&alice_config.index_dir()).unwrap();
|
||||
index
|
||||
.add_collection(&collection("mine", &alice_local, false, "metadata"))
|
||||
.unwrap();
|
||||
}
|
||||
let alice = Node::start(alice_config).await.unwrap();
|
||||
|
||||
let http = client();
|
||||
let addr = alice.addr.to_string();
|
||||
|
||||
let (status, raw, value) = ask(&http, &addr, "rust", 1).await;
|
||||
assert!(status.is_success());
|
||||
assert_eq!(
|
||||
value.pointer("/local/total").and_then(|v| v.as_u64()),
|
||||
Some(1)
|
||||
);
|
||||
let responses = value.get("responses").and_then(|v| v.as_array()).unwrap();
|
||||
assert_eq!(responses.len(), 1, "expected one responder: {value}");
|
||||
let response = &responses[0];
|
||||
let results = response.get("results").and_then(|v| v.as_array()).unwrap();
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(
|
||||
response.get("truncated").and_then(|v| v.as_bool()),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(
|
||||
response.get("more_available").and_then(|v| v.as_u64()),
|
||||
Some(1)
|
||||
);
|
||||
assert!(!raw.contains("gamma"), "unshared collection leaked: {raw}");
|
||||
assert!(!raw.contains("\"score\""), "response carries scores: {raw}");
|
||||
|
||||
let (_status, _raw, value) = ask(&http, &addr, "rust", 5).await;
|
||||
let responses = value.get("responses").and_then(|v| v.as_array()).unwrap();
|
||||
assert_eq!(responses.len(), 1);
|
||||
assert_eq!(
|
||||
responses[0]
|
||||
.get("results")
|
||||
.and_then(|v| v.as_array())
|
||||
.unwrap()
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
assert_eq!(
|
||||
responses[0].get("truncated").and_then(|v| v.as_bool()),
|
||||
Some(false)
|
||||
);
|
||||
|
||||
let (_status, _raw, value) = ask(&http, &addr, "secret launch codes", 5).await;
|
||||
let responses = value.get("responses").and_then(|v| v.as_array()).unwrap();
|
||||
assert!(
|
||||
responses.is_empty(),
|
||||
"private collection was served: {value}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn relay_rejects_non_query_broadcasts() {
|
||||
let relay_url = spawn_relay().await;
|
||||
let key = frxd::crypto::Keypair::generate();
|
||||
let envelope = Envelope::new(
|
||||
&key,
|
||||
TYPE_RESPONSE,
|
||||
serde_json::json!({"qid": "x", "results": []}),
|
||||
);
|
||||
let response = publish(&client(), &relay_url, &envelope).await;
|
||||
assert_eq!(response.status(), reqwest::StatusCode::BAD_REQUEST);
|
||||
}
|
||||
+389
@@ -0,0 +1,389 @@
|
||||
mod common;
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::fs;
|
||||
|
||||
use common::{client, collection, config_for, poll, publish, spawn_relay, unicast};
|
||||
use frxd::crypto::Keypair;
|
||||
use frxd::index::LocalIndex;
|
||||
use frxd::message::{
|
||||
AggregateBody, Envelope, QueryBody, ResponseItem, TYPE_AGGREGATE, TYPE_QUERY, TYPE_RESPONSE,
|
||||
build_response,
|
||||
};
|
||||
use frxd::node::{self, Node};
|
||||
use serde_json::Value;
|
||||
|
||||
fn assert_exact_keys(value: &Value, expected: &[&str]) {
|
||||
let actual: BTreeSet<&str> = value
|
||||
.as_object()
|
||||
.expect("object")
|
||||
.keys()
|
||||
.map(String::as_str)
|
||||
.collect();
|
||||
let expected: BTreeSet<&str> = expected.iter().copied().collect();
|
||||
assert_eq!(actual, expected, "fields drifted: {value}");
|
||||
}
|
||||
|
||||
fn assert_absent_fields(value: &Value, banned: &[&str]) {
|
||||
let raw = serde_json::to_string(value).unwrap();
|
||||
for field in banned {
|
||||
assert!(
|
||||
!raw.contains(&format!("\"{field}\"")),
|
||||
"purged field '{field}' present: {raw}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_bounty_winner_selection_or_slashing() {
|
||||
let query = serde_json::to_value(QueryBody::new("rust", 5)).unwrap();
|
||||
assert_exact_keys(&query, &["qid", "text", "entities", "budget"]);
|
||||
assert_exact_keys(query.get("budget").unwrap(), &["max_results"]);
|
||||
let envelope = Envelope::new(&Keypair::generate(), TYPE_QUERY, query.clone());
|
||||
let envelope = serde_json::to_value(&envelope).unwrap();
|
||||
for value in [&query, &envelope] {
|
||||
assert_absent_fields(
|
||||
value,
|
||||
&[
|
||||
"bounty",
|
||||
"winner",
|
||||
"winner_selection",
|
||||
"slashing",
|
||||
"stake",
|
||||
"reward",
|
||||
"payment",
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn no_protocol_query_dedup() {
|
||||
let relay_url = spawn_relay().await;
|
||||
let http = client();
|
||||
let key = Keypair::generate();
|
||||
let envelope = Envelope::new(
|
||||
&key,
|
||||
TYPE_QUERY,
|
||||
serde_json::json!({"qid": "q1", "text": "rust"}),
|
||||
);
|
||||
assert!(
|
||||
publish(&http, &relay_url, &envelope)
|
||||
.await
|
||||
.status()
|
||||
.is_success()
|
||||
);
|
||||
assert!(
|
||||
publish(&http, &relay_url, &envelope)
|
||||
.await
|
||||
.status()
|
||||
.is_success()
|
||||
);
|
||||
let response = poll(&http, &relay_url, &key.public_hex(), 300).await;
|
||||
let payload: Value = response.json().await.unwrap();
|
||||
assert_eq!(
|
||||
payload
|
||||
.get("messages")
|
||||
.and_then(Value::as_array)
|
||||
.map(Vec::len),
|
||||
Some(2),
|
||||
"relay deduplicated identical queries: {payload}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_k_fetch_ingestion_attestations() {
|
||||
let response = build_response("q1", Vec::new(), 0, 5);
|
||||
let value = serde_json::to_value(&response).unwrap();
|
||||
assert_exact_keys(
|
||||
&value,
|
||||
&["qid", "results", "truncated", "more_available", "cursor"],
|
||||
);
|
||||
assert_absent_fields(
|
||||
&value,
|
||||
&[
|
||||
"attestation",
|
||||
"attestations",
|
||||
"k_fetch",
|
||||
"fetch_proof",
|
||||
"ingestion_proof",
|
||||
"fetch_count",
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_result_count_etiquette() {
|
||||
let query = serde_json::to_value(QueryBody::new("rust", 5)).unwrap();
|
||||
assert_absent_fields(
|
||||
&query,
|
||||
&["min_results", "results_count", "serp", "count_floor"],
|
||||
);
|
||||
let response = serde_json::to_value(build_response("q1", Vec::new(), 0, 5)).unwrap();
|
||||
assert_absent_fields(
|
||||
&response,
|
||||
&["min_results", "results_count", "serp", "count_floor"],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_global_reputation_score() {
|
||||
let item = ResponseItem {
|
||||
url: "file:///x".to_string(),
|
||||
title: "t".to_string(),
|
||||
summary: String::new(),
|
||||
published: String::new(),
|
||||
exposure: "metadata".to_string(),
|
||||
content: None,
|
||||
};
|
||||
let item_value = serde_json::to_value(&item).unwrap();
|
||||
assert_exact_keys(
|
||||
&item_value,
|
||||
&[
|
||||
"url",
|
||||
"title",
|
||||
"summary",
|
||||
"published",
|
||||
"exposure",
|
||||
"content",
|
||||
],
|
||||
);
|
||||
let response_value = serde_json::to_value(build_response("q1", vec![item], 1, 5)).unwrap();
|
||||
for value in [&item_value, &response_value] {
|
||||
assert_absent_fields(value, &["score", "rank", "reputation", "weight", "rating"]);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn no_aggregate_appeals() {
|
||||
let relay_url = spawn_relay().await;
|
||||
let http = client();
|
||||
let sender = Keypair::generate();
|
||||
let member = Keypair::generate();
|
||||
|
||||
let dispute = Envelope::new(
|
||||
&sender,
|
||||
"dispute",
|
||||
serde_json::json!({"about": "counter", "reason": "unfair"}),
|
||||
);
|
||||
assert_eq!(
|
||||
publish(&http, &relay_url, &dispute).await.status(),
|
||||
reqwest::StatusCode::BAD_REQUEST
|
||||
);
|
||||
assert_eq!(
|
||||
unicast(&http, &relay_url, &member.public_hex(), &dispute)
|
||||
.await
|
||||
.status(),
|
||||
reqwest::StatusCode::BAD_REQUEST
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
poll(&http, &relay_url, &member.public_hex(), 30)
|
||||
.await
|
||||
.status(),
|
||||
reqwest::StatusCode::NO_CONTENT
|
||||
);
|
||||
let aggregate = Envelope::new(
|
||||
&sender,
|
||||
TYPE_AGGREGATE,
|
||||
serde_json::to_value(AggregateBody {
|
||||
period: "2026-03".to_string(),
|
||||
sent: 1,
|
||||
passed: 1,
|
||||
cited: 0,
|
||||
})
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(
|
||||
unicast(&http, &relay_url, &member.public_hex(), &aggregate)
|
||||
.await
|
||||
.status(),
|
||||
reqwest::StatusCode::OK
|
||||
);
|
||||
|
||||
let body = serde_json::to_value(AggregateBody {
|
||||
period: "2026-03".to_string(),
|
||||
sent: 1,
|
||||
passed: 1,
|
||||
cited: 0,
|
||||
})
|
||||
.unwrap();
|
||||
assert_exact_keys(&body, &["period", "sent", "passed", "cited"]);
|
||||
assert_absent_fields(&body, &["appeal", "dispute", "complaint", "sanction"]);
|
||||
|
||||
for route in ["/v1/appeal", "/v1/dispute"] {
|
||||
let response = http
|
||||
.get(format!("{relay_url}{route}"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), reqwest::StatusCode::NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn no_topic_channels() {
|
||||
let query = serde_json::to_value(QueryBody::new("rust", 5)).unwrap();
|
||||
assert_exact_keys(&query, &["qid", "text", "entities", "budget"]);
|
||||
assert_absent_fields(
|
||||
&query,
|
||||
&[
|
||||
"topic", "channel", "taxonomy", "category", "routing", "topic_id",
|
||||
],
|
||||
);
|
||||
|
||||
let relay_url = spawn_relay().await;
|
||||
let http = client();
|
||||
let alice = Keypair::generate();
|
||||
let bob = Keypair::generate();
|
||||
assert_eq!(
|
||||
poll(&http, &relay_url, &alice.public_hex(), 30)
|
||||
.await
|
||||
.status(),
|
||||
reqwest::StatusCode::NO_CONTENT
|
||||
);
|
||||
assert_eq!(
|
||||
poll(&http, &relay_url, &bob.public_hex(), 30)
|
||||
.await
|
||||
.status(),
|
||||
reqwest::StatusCode::NO_CONTENT
|
||||
);
|
||||
let envelope = Envelope::new(
|
||||
&alice,
|
||||
TYPE_QUERY,
|
||||
serde_json::json!({"qid": "q1", "text": "rust"}),
|
||||
);
|
||||
assert!(
|
||||
publish(&http, &relay_url, &envelope)
|
||||
.await
|
||||
.status()
|
||||
.is_success()
|
||||
);
|
||||
for member in [&alice, &bob] {
|
||||
let response = poll(&http, &relay_url, &member.public_hex(), 300).await;
|
||||
let payload: Value = response.json().await.unwrap();
|
||||
assert_eq!(
|
||||
payload
|
||||
.get("messages")
|
||||
.and_then(Value::as_array)
|
||||
.map(Vec::len),
|
||||
Some(1),
|
||||
"receiver-side filtering expected, got {payload}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn no_broadcast_responses() {
|
||||
let relay_url = spawn_relay().await;
|
||||
let http = client();
|
||||
let key = Keypair::generate();
|
||||
for msg_type in [TYPE_RESPONSE, "evidence", "commons", "broadcast_response"] {
|
||||
let envelope = Envelope::new(&key, msg_type, serde_json::json!({}));
|
||||
assert_eq!(
|
||||
publish(&http, &relay_url, &envelope).await.status(),
|
||||
reqwest::StatusCode::BAD_REQUEST,
|
||||
"relay accepted broadcast {msg_type}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn no_normative_query_canonical_form() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let docs = root.path().join("docs");
|
||||
fs::create_dir_all(&docs).unwrap();
|
||||
fs::write(docs.join("alpha.txt"), "rust ownership and borrowing").unwrap();
|
||||
let config = config_for(&root.path().join("alice"), "alice", "http://127.0.0.1:1");
|
||||
{
|
||||
let index = LocalIndex::open(&config.index_dir()).unwrap();
|
||||
index
|
||||
.add_collection(&collection("docs", &docs, true, "full"))
|
||||
.unwrap();
|
||||
}
|
||||
let node = Node::start(config).await.unwrap();
|
||||
let text = "rust?! ownership/borrowing: \"quotes\" ünïcode {braces} #tag";
|
||||
let outcome = node::control_query(
|
||||
&format!("http://{}", node.addr),
|
||||
text,
|
||||
Some(5),
|
||||
Some(100),
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(outcome.get("text").and_then(Value::as_str), Some(text));
|
||||
assert_eq!(
|
||||
outcome.pointer("/local/total").and_then(Value::as_u64),
|
||||
Some(1),
|
||||
"arbitrary phrasing should match lexically: {outcome}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn no_supply_announce_firehose() {
|
||||
let relay_url = spawn_relay().await;
|
||||
let http = client();
|
||||
let key = Keypair::generate();
|
||||
for msg_type in ["announce", "supply", "documents", "publish"] {
|
||||
let envelope = Envelope::new(&key, msg_type, serde_json::json!({}));
|
||||
assert_eq!(
|
||||
publish(&http, &relay_url, &envelope).await.status(),
|
||||
reqwest::StatusCode::BAD_REQUEST,
|
||||
"relay accepted supply announcement {msg_type}"
|
||||
);
|
||||
}
|
||||
for route in ["/v1/announce", "/v1/supply"] {
|
||||
let response = http
|
||||
.get(format!("{relay_url}{route}"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), reqwest::StatusCode::NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn no_durable_replayable_broadcast_stream() {
|
||||
let relay_url = spawn_relay().await;
|
||||
let http = client();
|
||||
let key = Keypair::generate();
|
||||
let envelope = Envelope::new(
|
||||
&key,
|
||||
TYPE_QUERY,
|
||||
serde_json::json!({"qid": "q1", "text": "rust"}),
|
||||
);
|
||||
assert!(
|
||||
publish(&http, &relay_url, &envelope)
|
||||
.await
|
||||
.status()
|
||||
.is_success()
|
||||
);
|
||||
|
||||
let response = poll(&http, &relay_url, &key.public_hex(), 300).await;
|
||||
let payload: Value = response.json().await.unwrap();
|
||||
assert_eq!(
|
||||
payload
|
||||
.get("messages")
|
||||
.and_then(Value::as_array)
|
||||
.map(Vec::len),
|
||||
Some(1)
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
poll(&http, &relay_url, &key.public_hex(), 50)
|
||||
.await
|
||||
.status(),
|
||||
reqwest::StatusCode::NO_CONTENT,
|
||||
"relay replayed a drained message"
|
||||
);
|
||||
|
||||
for route in ["/v1/history", "/v1/replay"] {
|
||||
let response = http
|
||||
.get(format!("{relay_url}{route}"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), reqwest::StatusCode::NOT_FOUND);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
use std::fs;
|
||||
use std::time::Instant;
|
||||
|
||||
use frxd::index::{Collection, LocalIndex, response_items};
|
||||
use frxd::message::{EXPOSURE_FULL, build_response};
|
||||
|
||||
#[test]
|
||||
fn thousand_file_corpus_is_searchable_and_honest() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let docs = temp.path().join("corpus");
|
||||
fs::create_dir_all(&docs).unwrap();
|
||||
let total = 1000;
|
||||
for i in 0..total {
|
||||
let part = docs.join(format!("part{:02}", i / 100));
|
||||
fs::create_dir_all(&part).unwrap();
|
||||
fs::write(
|
||||
part.join(format!("doc{i:04}.txt")),
|
||||
format!("commonneedle unique{i} rust document number {i}"),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let index = LocalIndex::open(&temp.path().join("index")).unwrap();
|
||||
let start = Instant::now();
|
||||
let added = index
|
||||
.add_collection(&Collection {
|
||||
name: "corpus".to_string(),
|
||||
path: docs.display().to_string(),
|
||||
shared: true,
|
||||
exposure: EXPOSURE_FULL.to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
println!("indexed {added} documents in {:?}", start.elapsed());
|
||||
assert_eq!(added, total);
|
||||
assert_eq!(index.doc_count(), total as u64);
|
||||
|
||||
let start = Instant::now();
|
||||
let (hits, matches) = index.search("commonneedle", 10, true).unwrap();
|
||||
println!("query matched {matches} documents in {:?}", start.elapsed());
|
||||
assert_eq!(matches, total as u64);
|
||||
assert_eq!(hits.len(), 10);
|
||||
|
||||
let response = build_response("scale", response_items(&hits), matches, 10);
|
||||
assert!(response.truncated);
|
||||
assert_eq!(response.more_available, (total - 10) as u64);
|
||||
assert!(serde_json::to_string(&response).unwrap().len() > 0);
|
||||
|
||||
let (hits, matches) = index.search("unique777", 10, true).unwrap();
|
||||
assert_eq!(matches, 1);
|
||||
assert!(hits[0].summary.contains("unique777"));
|
||||
}
|
||||
Reference in New Issue
Block a user