925 lines
28 KiB
Rust
925 lines
28 KiB
Rust
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()
|
|
])
|
|
);
|
|
}
|