Add Phase 1 frxd implementation with conformance test suite

This commit is contained in:
George Coles
2026-09-15 03:07:17 -04:00
parent 81ef27179b
commit 82a95fb907
23 changed files with 7295 additions and 3 deletions
+250
View File
@@ -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"));
}
+163
View File
@@ -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()
}
+210
View File
@@ -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()
);
}
+924
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
}
+51
View File
@@ -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"));
}