mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
1422 lines
46 KiB
Rust
1422 lines
46 KiB
Rust
//! E2E tests for the Sprout REST API.
|
|
//!
|
|
//! These tests require a running relay instance with `require_auth_token=false`
|
|
//! (dev mode). By default they are marked `#[ignore]` so that `cargo test`
|
|
//! does not fail in CI when the relay is not available.
|
|
//!
|
|
//! # Running
|
|
//!
|
|
//! Start the relay, then run:
|
|
//!
|
|
//! ```text
|
|
//! RELAY_URL=ws://localhost:3001 cargo test -p sprout-test-client --test e2e_rest_api -- --ignored
|
|
//! ```
|
|
//!
|
|
//! # Auth
|
|
//!
|
|
//! In dev mode (`require_auth_token=false`) the relay accepts an
|
|
//! `X-Pubkey: <hex>` header as authentication. Tests generate fresh
|
|
//! [`nostr::Keys`] per test and pass the hex-encoded public key.
|
|
//!
|
|
//! # Channel setup
|
|
//!
|
|
//! Each test creates its own channels dynamically via `POST /api/channels`.
|
|
//! No pre-seeded data is required — tests are fully self-contained and work
|
|
//! against a fresh database. Some tests also send messages via WebSocket to
|
|
//! set up search / feed data.
|
|
|
|
use std::time::Duration;
|
|
|
|
use nostr::{Alphabet, Filter, Keys, Kind, SingleLetterTag, Tag};
|
|
use reqwest::Client;
|
|
use sprout_test_client::{RelayMessage, SproutTestClient};
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
/// WebSocket relay URL (e.g. `ws://localhost:3001`).
|
|
fn relay_ws_url() -> String {
|
|
std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3001".to_string())
|
|
}
|
|
|
|
/// HTTP base URL derived from the WebSocket URL.
|
|
fn relay_http_url() -> String {
|
|
relay_ws_url()
|
|
.replace("wss://", "https://")
|
|
.replace("ws://", "http://")
|
|
}
|
|
|
|
/// Build a `reqwest::Client` with a short timeout.
|
|
fn http_client() -> Client {
|
|
Client::builder()
|
|
.timeout(Duration::from_secs(10))
|
|
.build()
|
|
.expect("failed to build HTTP client")
|
|
}
|
|
|
|
/// Make an authenticated GET request using the `X-Pubkey` dev-mode header.
|
|
async fn authed_get(client: &Client, url: &str, pubkey_hex: &str) -> reqwest::Response {
|
|
client
|
|
.get(url)
|
|
.header("X-Pubkey", pubkey_hex)
|
|
.send()
|
|
.await
|
|
.unwrap_or_else(|e| panic!("HTTP GET {url} failed: {e}"))
|
|
}
|
|
|
|
/// Make an authenticated POST request using the `X-Pubkey` dev-mode header.
|
|
async fn authed_post_json(
|
|
client: &Client,
|
|
url: &str,
|
|
pubkey_hex: &str,
|
|
body: serde_json::Value,
|
|
) -> reqwest::Response {
|
|
client
|
|
.post(url)
|
|
.header("X-Pubkey", pubkey_hex)
|
|
.json(&body)
|
|
.send()
|
|
.await
|
|
.unwrap_or_else(|e| panic!("HTTP POST {url} failed: {e}"))
|
|
}
|
|
|
|
/// Make an authenticated PUT request using the `X-Pubkey` dev-mode header.
|
|
async fn authed_put(
|
|
client: &Client,
|
|
url: &str,
|
|
pubkey_hex: &str,
|
|
body: serde_json::Value,
|
|
) -> reqwest::Response {
|
|
client
|
|
.put(url)
|
|
.header("X-Pubkey", pubkey_hex)
|
|
.json(&body)
|
|
.send()
|
|
.await
|
|
.unwrap_or_else(|e| panic!("HTTP PUT {url} failed: {e}"))
|
|
}
|
|
|
|
// ── Channel tests ─────────────────────────────────────────────────────────────
|
|
|
|
/// GET /api/channels returns a non-empty list with the expected fields.
|
|
#[tokio::test]
|
|
#[ignore]
|
|
async fn test_list_channels_returns_expected_fields() {
|
|
let client = http_client();
|
|
let keys = Keys::generate();
|
|
let pubkey_hex = keys.public_key().to_hex();
|
|
|
|
let url = format!("{}/api/channels", relay_http_url());
|
|
|
|
// Ensure at least one channel exists (fresh DB may be empty).
|
|
let seed_resp = authed_post_json(
|
|
&client,
|
|
&url,
|
|
&pubkey_hex,
|
|
serde_json::json!({
|
|
"name": format!("list-test-{}", uuid::Uuid::new_v4()),
|
|
"channel_type": "stream",
|
|
"visibility": "open",
|
|
"description": "Seed channel for list test"
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
seed_resp.status(),
|
|
201,
|
|
"bootstrap channel creation must succeed"
|
|
);
|
|
|
|
let resp = authed_get(&client, &url, &pubkey_hex).await;
|
|
|
|
assert_eq!(resp.status(), 200, "expected 200 OK from /api/channels");
|
|
|
|
let body: serde_json::Value = resp.json().await.expect("response must be JSON");
|
|
let channels = body
|
|
.as_array()
|
|
.expect("/api/channels must return a JSON array");
|
|
|
|
assert!(
|
|
!channels.is_empty(),
|
|
"expected at least one channel in the list"
|
|
);
|
|
|
|
for ch in channels {
|
|
assert!(ch.get("id").is_some(), "channel missing 'id' field");
|
|
assert!(ch.get("name").is_some(), "channel missing 'name' field");
|
|
assert!(
|
|
ch.get("channel_type").is_some(),
|
|
"channel missing 'channel_type' field"
|
|
);
|
|
assert!(
|
|
ch.get("description").is_some(),
|
|
"channel missing 'description' field"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// POST /api/channels creates a new channel owned by the requester.
|
|
#[tokio::test]
|
|
#[ignore]
|
|
async fn test_create_channel_returns_channel_record() {
|
|
let client = http_client();
|
|
let keys = Keys::generate();
|
|
let pubkey_hex = keys.public_key().to_hex();
|
|
let url = format!("{}/api/channels", relay_http_url());
|
|
let channel_name = format!("desktop-create-{}", uuid::Uuid::new_v4());
|
|
|
|
let resp = authed_post_json(
|
|
&client,
|
|
&url,
|
|
&pubkey_hex,
|
|
serde_json::json!({
|
|
"name": channel_name,
|
|
"channel_type": "stream",
|
|
"visibility": "private",
|
|
"description": "Created by the REST API test"
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
resp.status(),
|
|
201,
|
|
"expected 201 Created from POST /api/channels"
|
|
);
|
|
|
|
let created: serde_json::Value = resp.json().await.expect("response must be JSON");
|
|
assert!(created.get("id").is_some(), "channel missing 'id' field");
|
|
assert_eq!(created["name"].as_str(), Some(channel_name.as_str()));
|
|
assert_eq!(created["channel_type"].as_str(), Some("stream"));
|
|
assert_eq!(
|
|
created["description"].as_str(),
|
|
Some("Created by the REST API test")
|
|
);
|
|
|
|
let list_resp = authed_get(&client, &url, &pubkey_hex).await;
|
|
assert_eq!(
|
|
list_resp.status(),
|
|
200,
|
|
"expected 200 OK from /api/channels"
|
|
);
|
|
let channels: Vec<serde_json::Value> = list_resp.json().await.expect("response must be JSON");
|
|
assert!(
|
|
channels
|
|
.iter()
|
|
.any(|channel| channel["id"] == created["id"] && channel["name"] == created["name"]),
|
|
"newly-created private channel should be visible to its creator"
|
|
);
|
|
}
|
|
|
|
/// Open channels are visible to any authenticated user (no prior membership required).
|
|
#[tokio::test]
|
|
#[ignore]
|
|
async fn test_channel_visibility_open_channels_visible_to_all() {
|
|
let client = http_client();
|
|
|
|
// Use two completely independent keypairs — neither has any prior membership.
|
|
let keys_a = Keys::generate();
|
|
let keys_b = Keys::generate();
|
|
|
|
let url = format!("{}/api/channels", relay_http_url());
|
|
|
|
// Create an open channel as keys_a so there is at least one open channel to verify.
|
|
let open_channel_name = format!("e2e-open-{}", uuid::Uuid::new_v4().simple());
|
|
let create_resp = authed_post_json(
|
|
&client,
|
|
&url,
|
|
&keys_a.public_key().to_hex(),
|
|
serde_json::json!({
|
|
"name": open_channel_name,
|
|
"channel_type": "stream",
|
|
"visibility": "open"
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
create_resp.status(),
|
|
201,
|
|
"failed to create open channel for visibility test"
|
|
);
|
|
let created_channel: serde_json::Value = create_resp.json().await.expect("JSON");
|
|
let open_channel_id = created_channel["id"]
|
|
.as_str()
|
|
.expect("created channel must have id")
|
|
.to_string();
|
|
|
|
let resp_a = authed_get(&client, &url, &keys_a.public_key().to_hex()).await;
|
|
let resp_b = authed_get(&client, &url, &keys_b.public_key().to_hex()).await;
|
|
|
|
assert_eq!(resp_a.status(), 200);
|
|
assert_eq!(resp_b.status(), 200);
|
|
|
|
let channels_a: Vec<serde_json::Value> = resp_a.json().await.expect("JSON");
|
|
let channels_b: Vec<serde_json::Value> = resp_b.json().await.expect("JSON");
|
|
|
|
// Both users should see the open channel we just created.
|
|
let ids_a: std::collections::HashSet<String> = channels_a
|
|
.iter()
|
|
.filter_map(|c| c["id"].as_str().map(|s| s.to_string()))
|
|
.collect();
|
|
let ids_b: std::collections::HashSet<String> = channels_b
|
|
.iter()
|
|
.filter_map(|c| c["id"].as_str().map(|s| s.to_string()))
|
|
.collect();
|
|
|
|
assert!(
|
|
ids_a.contains(&open_channel_id),
|
|
"keys_a should see the open channel we created (id={open_channel_id})"
|
|
);
|
|
assert!(
|
|
ids_b.contains(&open_channel_id),
|
|
"keys_b (unrelated user) should also see the open channel (id={open_channel_id})"
|
|
);
|
|
}
|
|
|
|
/// REST-created channel messages must fan out to WebSocket subscribers and
|
|
/// carry the canonical channel `h` tag.
|
|
#[tokio::test]
|
|
#[ignore]
|
|
async fn test_rest_send_message_reaches_websocket_channel_subscriptions() {
|
|
let client = http_client();
|
|
let subscriber_keys = Keys::generate();
|
|
let poster_keys = Keys::generate();
|
|
let ws_url = relay_ws_url();
|
|
|
|
// Create a fresh open channel for this test.
|
|
let channels_url = format!("{}/api/channels", relay_http_url());
|
|
let channel_name = format!("e2e-rest-live-{}", uuid::Uuid::new_v4().simple());
|
|
let create_resp = authed_post_json(
|
|
&client,
|
|
&channels_url,
|
|
&poster_keys.public_key().to_hex(),
|
|
serde_json::json!({
|
|
"name": channel_name,
|
|
"channel_type": "stream",
|
|
"visibility": "open"
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(create_resp.status(), 201, "failed to create test channel");
|
|
let created: serde_json::Value = create_resp.json().await.expect("JSON");
|
|
let channel_id = created["id"]
|
|
.as_str()
|
|
.expect("channel must have id")
|
|
.to_string();
|
|
|
|
let mut subscriber = SproutTestClient::connect(&ws_url, &subscriber_keys)
|
|
.await
|
|
.expect("WebSocket connect failed");
|
|
|
|
let sid = format!("rest-live-{}", uuid::Uuid::new_v4().simple());
|
|
let filter = Filter::new().kind(Kind::Custom(40001)).custom_tag(
|
|
SingleLetterTag::lowercase(Alphabet::H),
|
|
[channel_id.as_str()],
|
|
);
|
|
|
|
subscriber
|
|
.subscribe(&sid, vec![filter])
|
|
.await
|
|
.expect("subscribe failed");
|
|
subscriber
|
|
.collect_until_eose(&sid, Duration::from_secs(5))
|
|
.await
|
|
.expect("EOSE failed");
|
|
|
|
let content = format!("E2E REST live message: {}", uuid::Uuid::new_v4().simple());
|
|
let url = format!("{}/api/channels/{}/messages", relay_http_url(), channel_id);
|
|
let resp = authed_post_json(
|
|
&client,
|
|
&url,
|
|
&poster_keys.public_key().to_hex(),
|
|
serde_json::json!({ "content": content }),
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(resp.status(), 200, "expected 200 OK from REST send_message");
|
|
|
|
let message = subscriber
|
|
.recv_event(Duration::from_secs(5))
|
|
.await
|
|
.expect("subscriber did not receive live event");
|
|
|
|
match message {
|
|
RelayMessage::Event { event, .. } => {
|
|
assert_eq!(event.content, content);
|
|
|
|
let tags: Vec<Vec<String>> = event
|
|
.tags
|
|
.iter()
|
|
.map(|tag| tag.as_slice().iter().map(|part| part.to_string()).collect())
|
|
.collect();
|
|
|
|
assert!(
|
|
tags.iter()
|
|
.any(|tag| tag.len() >= 2 && tag[0] == "h" && tag[1] == channel_id),
|
|
"REST-created message is missing the channel h tag: {tags:?}"
|
|
);
|
|
assert!(
|
|
tags.iter().any(|tag| {
|
|
tag.len() >= 2 && tag[0] == "p" && tag[1] == poster_keys.public_key().to_hex()
|
|
}),
|
|
"REST-created message is missing the sender attribution p tag: {tags:?}"
|
|
);
|
|
}
|
|
other => panic!("expected live EVENT after REST send_message, got {other:?}"),
|
|
}
|
|
|
|
subscriber.disconnect().await.expect("disconnect failed");
|
|
}
|
|
|
|
/// GET /api/channels requires authentication — unauthenticated requests are rejected.
|
|
#[tokio::test]
|
|
#[ignore]
|
|
async fn test_channels_requires_auth() {
|
|
let client = http_client();
|
|
let url = format!("{}/api/channels", relay_http_url());
|
|
|
|
// No X-Pubkey header.
|
|
let resp = client.get(&url).send().await.expect("request failed");
|
|
|
|
assert_eq!(
|
|
resp.status(),
|
|
401,
|
|
"expected 401 Unauthorized when no auth header is provided"
|
|
);
|
|
}
|
|
|
|
// ── Search tests ──────────────────────────────────────────────────────────────
|
|
|
|
/// GET /api/search returns results scoped to the authenticated user's accessible channels.
|
|
#[tokio::test]
|
|
#[ignore]
|
|
async fn test_search_returns_results_for_open_channels() {
|
|
let client = http_client();
|
|
let keys = Keys::generate();
|
|
let pubkey_hex = keys.public_key().to_hex();
|
|
|
|
// The seeded data contains messages with "Hello" — use a wildcard search
|
|
// to get all indexed events in accessible channels.
|
|
let url = format!("{}/api/search?q=*", relay_http_url());
|
|
let resp = authed_get(&client, &url, &pubkey_hex).await;
|
|
|
|
assert_eq!(resp.status(), 200, "expected 200 OK from /api/search");
|
|
|
|
let body: serde_json::Value = resp.json().await.expect("response must be JSON");
|
|
assert!(body.get("hits").is_some(), "response missing 'hits' field");
|
|
assert!(
|
|
body.get("found").is_some(),
|
|
"response missing 'found' field"
|
|
);
|
|
|
|
let hits = body["hits"].as_array().expect("'hits' must be an array");
|
|
|
|
for hit in hits {
|
|
assert!(hit.get("event_id").is_some(), "hit missing 'event_id'");
|
|
assert!(hit.get("content").is_some(), "hit missing 'content'");
|
|
assert!(hit.get("kind").is_some(), "hit missing 'kind'");
|
|
assert!(hit.get("pubkey").is_some(), "hit missing 'pubkey'");
|
|
assert!(hit.get("channel_id").is_some(), "hit missing 'channel_id'");
|
|
}
|
|
}
|
|
|
|
/// GET /api/search with a specific query returns only matching events.
|
|
#[tokio::test]
|
|
#[ignore]
|
|
async fn test_search_returns_indexed_event() {
|
|
let client = http_client();
|
|
let keys = Keys::generate();
|
|
let pubkey_hex = keys.public_key().to_hex();
|
|
let ws_url = relay_ws_url();
|
|
|
|
// Create a channel for this test so the event is accepted by the relay.
|
|
let channels_url = format!("{}/api/channels", relay_http_url());
|
|
let channel_name = format!("e2e-search-{}", uuid::Uuid::new_v4().simple());
|
|
let create_resp = authed_post_json(
|
|
&client,
|
|
&channels_url,
|
|
&pubkey_hex,
|
|
serde_json::json!({
|
|
"name": channel_name,
|
|
"channel_type": "stream",
|
|
"visibility": "open"
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(create_resp.status(), 201, "failed to create test channel");
|
|
let created: serde_json::Value = create_resp.json().await.expect("JSON");
|
|
let channel_id = created["id"]
|
|
.as_str()
|
|
.expect("channel must have id")
|
|
.to_string();
|
|
|
|
let unique_token = format!("e2e-search-{}", uuid::Uuid::new_v4().simple());
|
|
let content = format!("E2E REST search test marker: {unique_token}");
|
|
|
|
let mut ws_client = SproutTestClient::connect(&ws_url, &keys)
|
|
.await
|
|
.expect("WebSocket connect failed");
|
|
|
|
let h_tag = Tag::parse(&["h", &channel_id]).expect("tag parse failed");
|
|
let event = nostr::EventBuilder::new(Kind::Custom(40001), &content, [h_tag])
|
|
.sign_with_keys(&keys)
|
|
.expect("event sign failed");
|
|
|
|
let ok = ws_client
|
|
.send_event(event)
|
|
.await
|
|
.expect("send_event failed");
|
|
assert!(ok.accepted, "relay rejected event: {}", ok.message);
|
|
|
|
ws_client.disconnect().await.ok();
|
|
|
|
// Wait briefly for the search index to catch up.
|
|
tokio::time::sleep(Duration::from_millis(500)).await;
|
|
|
|
// The unique_token is UUID simple format (hex only) — safe to use directly in the URL.
|
|
let url = format!("{}/api/search?q={unique_token}", relay_http_url());
|
|
let resp = authed_get(&client, &url, &pubkey_hex).await;
|
|
|
|
assert_eq!(resp.status(), 200);
|
|
|
|
let body: serde_json::Value = resp.json().await.expect("JSON");
|
|
let hits = body["hits"].as_array().expect("hits array");
|
|
|
|
assert!(
|
|
!hits.is_empty(),
|
|
"expected at least one search hit for unique token '{unique_token}'"
|
|
);
|
|
|
|
let first_content = hits[0]["content"].as_str().unwrap_or("");
|
|
assert!(
|
|
first_content.contains(&unique_token),
|
|
"expected hit content to contain '{unique_token}', got: '{first_content}'"
|
|
);
|
|
}
|
|
|
|
/// GET /api/search with empty query returns all accessible events.
|
|
#[tokio::test]
|
|
#[ignore]
|
|
async fn test_search_empty_query_returns_all() {
|
|
let client = http_client();
|
|
let keys = Keys::generate();
|
|
let pubkey_hex = keys.public_key().to_hex();
|
|
|
|
let url = format!("{}/api/search", relay_http_url());
|
|
let resp = authed_get(&client, &url, &pubkey_hex).await;
|
|
|
|
assert_eq!(resp.status(), 200);
|
|
|
|
let body: serde_json::Value = resp.json().await.expect("JSON");
|
|
assert!(body["hits"].is_array(), "'hits' must be an array");
|
|
assert!(body["found"].is_number(), "'found' must be a number");
|
|
}
|
|
|
|
// ── Presence tests ────────────────────────────────────────────────────────────
|
|
|
|
/// GET /api/presence returns "offline" for a pubkey with no presence event.
|
|
#[tokio::test]
|
|
#[ignore]
|
|
async fn test_presence_offline_by_default() {
|
|
let client = http_client();
|
|
let keys = Keys::generate();
|
|
let pubkey_hex = keys.public_key().to_hex();
|
|
|
|
let url = format!("{}/api/presence?pubkeys={pubkey_hex}", relay_http_url());
|
|
let resp = authed_get(&client, &url, &pubkey_hex).await;
|
|
|
|
assert_eq!(resp.status(), 200);
|
|
|
|
let body: serde_json::Value = resp.json().await.expect("JSON");
|
|
let status = body[&pubkey_hex].as_str().expect("expected string status");
|
|
assert_eq!(status, "offline", "fresh key should be 'offline'");
|
|
}
|
|
|
|
/// Sending a presence event (kind:20001) via WebSocket updates the presence store.
|
|
#[tokio::test]
|
|
#[ignore]
|
|
async fn test_presence_set_and_query() {
|
|
let client = http_client();
|
|
let keys = Keys::generate();
|
|
let pubkey_hex = keys.public_key().to_hex();
|
|
let ws_url = relay_ws_url();
|
|
|
|
let mut ws_client = SproutTestClient::connect(&ws_url, &keys)
|
|
.await
|
|
.expect("WebSocket connect failed");
|
|
|
|
let presence_event = nostr::EventBuilder::new(Kind::Custom(20001), "online", [])
|
|
.sign_with_keys(&keys)
|
|
.expect("event sign failed");
|
|
|
|
let ok = ws_client
|
|
.send_event(presence_event)
|
|
.await
|
|
.expect("send_event failed");
|
|
assert!(ok.accepted, "relay rejected presence event: {}", ok.message);
|
|
|
|
// Keep the WebSocket connection alive briefly so presence is registered.
|
|
tokio::time::sleep(Duration::from_millis(200)).await;
|
|
|
|
let url = format!("{}/api/presence?pubkeys={pubkey_hex}", relay_http_url());
|
|
let resp = authed_get(&client, &url, &pubkey_hex).await;
|
|
|
|
assert_eq!(resp.status(), 200);
|
|
|
|
let body: serde_json::Value = resp.json().await.expect("JSON");
|
|
let status = body[&pubkey_hex].as_str().expect("expected string status");
|
|
assert_eq!(
|
|
status, "online",
|
|
"expected 'online' after sending presence event"
|
|
);
|
|
|
|
let offline_event = nostr::EventBuilder::new(Kind::Custom(20001), "offline", [])
|
|
.sign_with_keys(&keys)
|
|
.expect("event sign failed");
|
|
ws_client.send_event(offline_event).await.ok();
|
|
ws_client.disconnect().await.ok();
|
|
}
|
|
|
|
/// GET /api/presence with multiple pubkeys returns a status for each.
|
|
#[tokio::test]
|
|
#[ignore]
|
|
async fn test_presence_bulk_query() {
|
|
let client = http_client();
|
|
let keys = Keys::generate();
|
|
let pubkey_hex = keys.public_key().to_hex();
|
|
|
|
// Generate two fresh keys — both should be offline.
|
|
let keys_a = Keys::generate();
|
|
let keys_b = Keys::generate();
|
|
let pk_a = keys_a.public_key().to_hex();
|
|
let pk_b = keys_b.public_key().to_hex();
|
|
|
|
let url = format!("{}/api/presence?pubkeys={pk_a},{pk_b}", relay_http_url());
|
|
let resp = authed_get(&client, &url, &pubkey_hex).await;
|
|
|
|
assert_eq!(resp.status(), 200);
|
|
|
|
let body: serde_json::Value = resp.json().await.expect("JSON");
|
|
assert!(body.is_object(), "presence response must be an object");
|
|
|
|
assert!(
|
|
body.get(&pk_a).is_some(),
|
|
"pk_a missing from presence response"
|
|
);
|
|
assert!(
|
|
body.get(&pk_b).is_some(),
|
|
"pk_b missing from presence response"
|
|
);
|
|
|
|
assert_eq!(body[&pk_a].as_str(), Some("offline"));
|
|
assert_eq!(body[&pk_b].as_str(), Some("offline"));
|
|
}
|
|
|
|
/// GET /api/presence with no pubkeys returns an empty object.
|
|
#[tokio::test]
|
|
#[ignore]
|
|
async fn test_presence_empty_pubkeys() {
|
|
let client = http_client();
|
|
let keys = Keys::generate();
|
|
let pubkey_hex = keys.public_key().to_hex();
|
|
|
|
let url = format!("{}/api/presence", relay_http_url());
|
|
let resp = authed_get(&client, &url, &pubkey_hex).await;
|
|
|
|
assert_eq!(resp.status(), 200);
|
|
|
|
let body: serde_json::Value = resp.json().await.expect("JSON");
|
|
assert!(
|
|
body.as_object().map(|o| o.is_empty()).unwrap_or(false),
|
|
"expected empty object for no pubkeys"
|
|
);
|
|
}
|
|
|
|
// ── Agents tests ──────────────────────────────────────────────────────────────
|
|
|
|
/// GET /api/agents returns a JSON array with the expected fields.
|
|
#[tokio::test]
|
|
#[ignore]
|
|
async fn test_agents_list() {
|
|
let client = http_client();
|
|
let keys = Keys::generate();
|
|
let pubkey_hex = keys.public_key().to_hex();
|
|
|
|
let url = format!("{}/api/agents", relay_http_url());
|
|
let resp = authed_get(&client, &url, &pubkey_hex).await;
|
|
|
|
assert_eq!(resp.status(), 200, "expected 200 OK from /api/agents");
|
|
|
|
let body: serde_json::Value = resp.json().await.expect("response must be JSON");
|
|
let agents = body
|
|
.as_array()
|
|
.expect("/api/agents must return a JSON array");
|
|
|
|
for agent in agents {
|
|
assert!(agent.get("pubkey").is_some(), "agent missing 'pubkey'");
|
|
assert!(agent.get("name").is_some(), "agent missing 'name'");
|
|
assert!(agent.get("status").is_some(), "agent missing 'status'");
|
|
assert!(agent.get("channels").is_some(), "agent missing 'channels'");
|
|
assert!(
|
|
agent.get("capabilities").is_some(),
|
|
"agent missing 'capabilities'"
|
|
);
|
|
|
|
// 'channels' must be an array.
|
|
assert!(
|
|
agent["channels"].is_array(),
|
|
"agent 'channels' must be an array"
|
|
);
|
|
// 'capabilities' must be an array.
|
|
assert!(
|
|
agent["capabilities"].is_array(),
|
|
"agent 'capabilities' must be an array"
|
|
);
|
|
// 'status' must be a string.
|
|
assert!(
|
|
agent["status"].is_string(),
|
|
"agent 'status' must be a string"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// GET /api/agents requires authentication.
|
|
#[tokio::test]
|
|
#[ignore]
|
|
async fn test_agents_requires_auth() {
|
|
let client = http_client();
|
|
let url = format!("{}/api/agents", relay_http_url());
|
|
|
|
let resp = client.get(&url).send().await.expect("request failed");
|
|
|
|
assert_eq!(
|
|
resp.status(),
|
|
401,
|
|
"expected 401 Unauthorized when no auth header is provided"
|
|
);
|
|
}
|
|
|
|
/// GET /api/agents only returns agents in channels accessible to the requester.
|
|
#[tokio::test]
|
|
#[ignore]
|
|
async fn test_agents_scoped_to_accessible_channels() {
|
|
let client = http_client();
|
|
let keys = Keys::generate();
|
|
let pubkey_hex = keys.public_key().to_hex();
|
|
|
|
let url = format!("{}/api/agents", relay_http_url());
|
|
let resp = authed_get(&client, &url, &pubkey_hex).await;
|
|
|
|
assert_eq!(resp.status(), 200);
|
|
|
|
let agents: Vec<serde_json::Value> = resp.json().await.expect("JSON");
|
|
|
|
let channels_url = format!("{}/api/channels", relay_http_url());
|
|
let channels_resp = authed_get(&client, &channels_url, &pubkey_hex).await;
|
|
let channels: Vec<serde_json::Value> = channels_resp.json().await.expect("JSON");
|
|
let accessible_names: std::collections::HashSet<String> = channels
|
|
.iter()
|
|
.filter_map(|c| c["name"].as_str().map(|s| s.to_string()))
|
|
.collect();
|
|
|
|
// Every channel listed for each agent must be accessible to this user.
|
|
for agent in &agents {
|
|
let agent_channels = agent["channels"].as_array().expect("channels array");
|
|
for ch in agent_channels {
|
|
let ch_name = ch.as_str().expect("channel name must be a string");
|
|
assert!(
|
|
accessible_names.contains(ch_name),
|
|
"agent channel '{ch_name}' is not in the user's accessible channels"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Feed tests ────────────────────────────────────────────────────────────────
|
|
|
|
/// GET /api/feed returns a structured feed with the expected shape.
|
|
///
|
|
/// This test is skipped if the relay does not expose `/api/feed` (older builds).
|
|
#[tokio::test]
|
|
#[ignore]
|
|
async fn test_feed_returns_activity() {
|
|
let client = http_client();
|
|
let keys = Keys::generate();
|
|
let pubkey_hex = keys.public_key().to_hex();
|
|
let ws_url = relay_ws_url();
|
|
|
|
let url = format!("{}/api/feed", relay_http_url());
|
|
|
|
// Probe the endpoint — skip gracefully if the relay doesn't have it yet.
|
|
let probe = client
|
|
.get(&url)
|
|
.header("X-Pubkey", &pubkey_hex)
|
|
.send()
|
|
.await
|
|
.expect("probe request failed");
|
|
|
|
if probe.status() == 404 {
|
|
eprintln!("SKIP test_feed_returns_activity: /api/feed not available on this relay build");
|
|
return;
|
|
}
|
|
|
|
// Create a channel for this test so the event is accepted by the relay.
|
|
let channels_url = format!("{}/api/channels", relay_http_url());
|
|
let channel_name = format!("e2e-feed-{}", uuid::Uuid::new_v4().simple());
|
|
let create_resp = authed_post_json(
|
|
&client,
|
|
&channels_url,
|
|
&pubkey_hex,
|
|
serde_json::json!({
|
|
"name": channel_name,
|
|
"channel_type": "stream",
|
|
"visibility": "open"
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(create_resp.status(), 201, "failed to create test channel");
|
|
let created_channel: serde_json::Value = create_resp.json().await.expect("JSON");
|
|
let channel_id = created_channel["id"]
|
|
.as_str()
|
|
.expect("channel must have id")
|
|
.to_string();
|
|
|
|
// Send a message to the open channel so there is activity to return.
|
|
let unique_token = format!("e2e-feed-{}", uuid::Uuid::new_v4().simple());
|
|
let content = format!("E2E feed test: {unique_token}");
|
|
|
|
let mut ws_client = SproutTestClient::connect(&ws_url, &keys)
|
|
.await
|
|
.expect("WebSocket connect failed");
|
|
|
|
let h_tag = Tag::parse(&["h", &channel_id]).expect("tag parse failed");
|
|
let event = nostr::EventBuilder::new(Kind::Custom(40001), &content, [h_tag])
|
|
.sign_with_keys(&keys)
|
|
.expect("event sign failed");
|
|
|
|
let ok = ws_client
|
|
.send_event(event)
|
|
.await
|
|
.expect("send_event failed");
|
|
assert!(ok.accepted, "relay rejected event: {}", ok.message);
|
|
ws_client.disconnect().await.ok();
|
|
|
|
// Small delay to let the event propagate.
|
|
tokio::time::sleep(Duration::from_millis(200)).await;
|
|
|
|
let resp = authed_get(&client, &url, &pubkey_hex).await;
|
|
assert_eq!(resp.status(), 200, "expected 200 OK from /api/feed");
|
|
|
|
let body: serde_json::Value = resp.json().await.expect("response must be JSON");
|
|
|
|
let feed = body.get("feed").expect("response missing 'feed' key");
|
|
let meta = body.get("meta").expect("response missing 'meta' key");
|
|
|
|
assert!(feed.get("mentions").is_some(), "feed missing 'mentions'");
|
|
assert!(
|
|
feed.get("needs_action").is_some(),
|
|
"feed missing 'needs_action'"
|
|
);
|
|
assert!(feed.get("activity").is_some(), "feed missing 'activity'");
|
|
assert!(
|
|
feed.get("agent_activity").is_some(),
|
|
"feed missing 'agent_activity'"
|
|
);
|
|
|
|
assert!(meta.get("since").is_some(), "meta missing 'since'");
|
|
assert!(meta.get("total").is_some(), "meta missing 'total'");
|
|
assert!(
|
|
meta.get("generated_at").is_some(),
|
|
"meta missing 'generated_at'"
|
|
);
|
|
|
|
assert!(
|
|
feed["activity"].is_array(),
|
|
"feed 'activity' must be an array"
|
|
);
|
|
|
|
// The activity array should contain our message (it's in an open channel).
|
|
let activity = feed["activity"].as_array().expect("activity array");
|
|
let found = activity.iter().any(|item| {
|
|
item["content"]
|
|
.as_str()
|
|
.unwrap_or("")
|
|
.contains(&unique_token)
|
|
});
|
|
|
|
assert!(
|
|
found,
|
|
"expected to find our message '{unique_token}' in feed activity"
|
|
);
|
|
}
|
|
|
|
/// GET /api/feed with `types=activity` returns only the activity section.
|
|
#[tokio::test]
|
|
#[ignore]
|
|
async fn test_feed_type_filter() {
|
|
let client = http_client();
|
|
let keys = Keys::generate();
|
|
let pubkey_hex = keys.public_key().to_hex();
|
|
|
|
let url = format!("{}/api/feed?types=activity", relay_http_url());
|
|
|
|
let probe = client
|
|
.get(&url)
|
|
.header("X-Pubkey", &pubkey_hex)
|
|
.send()
|
|
.await
|
|
.expect("probe request failed");
|
|
|
|
if probe.status() == 404 {
|
|
eprintln!("SKIP test_feed_type_filter: /api/feed not available on this relay build");
|
|
return;
|
|
}
|
|
|
|
assert_eq!(probe.status(), 200);
|
|
|
|
let body: serde_json::Value = probe.json().await.expect("JSON");
|
|
let feed = &body["feed"];
|
|
|
|
// When filtering to 'activity', the other sections should be empty arrays.
|
|
assert_eq!(
|
|
feed["mentions"].as_array().map(|a| a.len()),
|
|
Some(0),
|
|
"mentions should be empty when types=activity"
|
|
);
|
|
assert_eq!(
|
|
feed["needs_action"].as_array().map(|a| a.len()),
|
|
Some(0),
|
|
"needs_action should be empty when types=activity"
|
|
);
|
|
}
|
|
|
|
/// GET /api/feed requires authentication.
|
|
#[tokio::test]
|
|
#[ignore]
|
|
async fn test_feed_requires_auth() {
|
|
let client = http_client();
|
|
let url = format!("{}/api/feed", relay_http_url());
|
|
|
|
let resp = client.get(&url).send().await.expect("request failed");
|
|
|
|
// Either 401 (auth required) or 404 (older build without feed route).
|
|
let status = resp.status().as_u16();
|
|
assert!(
|
|
status == 401 || status == 404,
|
|
"expected 401 or 404, got {status}"
|
|
);
|
|
}
|
|
|
|
// ── Auth edge cases ───────────────────────────────────────────────────────────
|
|
|
|
/// An invalid X-Pubkey header is rejected with 401.
|
|
#[tokio::test]
|
|
#[ignore]
|
|
async fn test_invalid_pubkey_header_rejected() {
|
|
let client = http_client();
|
|
let url = format!("{}/api/channels", relay_http_url());
|
|
|
|
let resp = client
|
|
.get(&url)
|
|
.header("X-Pubkey", "not-a-valid-hex-pubkey")
|
|
.send()
|
|
.await
|
|
.expect("request failed");
|
|
|
|
assert_eq!(
|
|
resp.status(),
|
|
401,
|
|
"expected 401 for invalid X-Pubkey header"
|
|
);
|
|
}
|
|
|
|
/// A valid X-Pubkey header is accepted and returns 200.
|
|
#[tokio::test]
|
|
#[ignore]
|
|
async fn test_valid_pubkey_header_accepted() {
|
|
let client = http_client();
|
|
let keys = Keys::generate();
|
|
let pubkey_hex = keys.public_key().to_hex();
|
|
|
|
let url = format!("{}/api/channels", relay_http_url());
|
|
let resp = authed_get(&client, &url, &pubkey_hex).await;
|
|
|
|
assert_eq!(resp.status(), 200, "expected 200 for valid X-Pubkey header");
|
|
}
|
|
|
|
// ── Public profile tests ──────────────────────────────────────────────────────
|
|
|
|
/// GET /api/users/:pubkey/profile returns the profile for a known user.
|
|
#[tokio::test]
|
|
#[ignore]
|
|
async fn test_get_user_profile_returns_known_user() {
|
|
let client = http_client();
|
|
let keys = Keys::generate();
|
|
let pubkey_hex = keys.public_key().to_hex();
|
|
|
|
// Set a profile first
|
|
let put_resp = client
|
|
.put(format!("{}/api/users/me/profile", relay_http_url()))
|
|
.header("X-Pubkey", &pubkey_hex)
|
|
.json(&serde_json::json!({
|
|
"display_name": "Profile Test User",
|
|
"about": "Testing public profile endpoint"
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("PUT profile");
|
|
assert_eq!(put_resp.status(), 200);
|
|
|
|
// Read it back via the new public endpoint (using a different reader)
|
|
let reader_keys = Keys::generate();
|
|
let reader_hex = reader_keys.public_key().to_hex();
|
|
|
|
let resp = authed_get(
|
|
&client,
|
|
&format!("{}/api/users/{}/profile", relay_http_url(), pubkey_hex),
|
|
&reader_hex,
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(resp.status(), 200, "expected 200 for known user profile");
|
|
let body: serde_json::Value = resp.json().await.expect("json");
|
|
assert_eq!(body["pubkey"].as_str(), Some(pubkey_hex.as_str()));
|
|
assert_eq!(body["display_name"].as_str(), Some("Profile Test User"));
|
|
assert_eq!(
|
|
body["about"].as_str(),
|
|
Some("Testing public profile endpoint")
|
|
);
|
|
}
|
|
|
|
/// GET /api/users/:pubkey/profile returns 404 for an unknown user.
|
|
#[tokio::test]
|
|
#[ignore]
|
|
async fn test_get_user_profile_returns_404_for_unknown() {
|
|
let client = http_client();
|
|
let keys = Keys::generate();
|
|
let pubkey_hex = keys.public_key().to_hex();
|
|
// Use a pubkey that has never been registered
|
|
let unknown_hex = Keys::generate().public_key().to_hex();
|
|
|
|
let resp = authed_get(
|
|
&client,
|
|
&format!("{}/api/users/{}/profile", relay_http_url(), unknown_hex),
|
|
&pubkey_hex,
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(resp.status(), 404, "expected 404 for unknown user");
|
|
}
|
|
|
|
/// GET /api/users/:pubkey/profile returns 401 without authentication.
|
|
#[tokio::test]
|
|
#[ignore]
|
|
async fn test_get_user_profile_requires_auth() {
|
|
let client = http_client();
|
|
let some_pubkey = Keys::generate().public_key().to_hex();
|
|
|
|
let resp = client
|
|
.get(format!(
|
|
"{}/api/users/{}/profile",
|
|
relay_http_url(),
|
|
some_pubkey
|
|
))
|
|
.send()
|
|
.await
|
|
.expect("GET profile");
|
|
|
|
assert_eq!(resp.status(), 401, "expected 401 without auth");
|
|
}
|
|
|
|
/// GET /api/users/:pubkey/profile returns 400 for an invalid pubkey.
|
|
#[tokio::test]
|
|
#[ignore]
|
|
async fn test_get_user_profile_rejects_invalid_pubkey() {
|
|
let client = http_client();
|
|
let keys = Keys::generate();
|
|
let pubkey_hex = keys.public_key().to_hex();
|
|
|
|
let resp = authed_get(
|
|
&client,
|
|
&format!(
|
|
"{}/api/users/{}/profile",
|
|
relay_http_url(),
|
|
"not-a-valid-hex"
|
|
),
|
|
&pubkey_hex,
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(resp.status(), 400, "expected 400 for invalid pubkey hex");
|
|
}
|
|
|
|
/// POST /api/users/batch returns found profiles and a missing list.
|
|
#[tokio::test]
|
|
#[ignore]
|
|
async fn test_batch_profiles_known_and_unknown() {
|
|
let client = http_client();
|
|
|
|
// Create two users with profiles
|
|
let keys_a = Keys::generate();
|
|
let hex_a = keys_a.public_key().to_hex();
|
|
let keys_b = Keys::generate();
|
|
let hex_b = keys_b.public_key().to_hex();
|
|
let unknown_hex = Keys::generate().public_key().to_hex();
|
|
|
|
client
|
|
.put(format!("{}/api/users/me/profile", relay_http_url()))
|
|
.header("X-Pubkey", &hex_a)
|
|
.json(&serde_json::json!({"display_name": "Alice Batch"}))
|
|
.send()
|
|
.await
|
|
.expect("PUT alice");
|
|
|
|
client
|
|
.put(format!("{}/api/users/me/profile", relay_http_url()))
|
|
.header("X-Pubkey", &hex_b)
|
|
.json(&serde_json::json!({"display_name": "Bob Batch"}))
|
|
.send()
|
|
.await
|
|
.expect("PUT bob");
|
|
|
|
// Batch lookup
|
|
let resp = authed_post_json(
|
|
&client,
|
|
&format!("{}/api/users/batch", relay_http_url()),
|
|
&hex_a,
|
|
serde_json::json!({
|
|
"pubkeys": [hex_a, hex_b, unknown_hex]
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(resp.status(), 200);
|
|
let body: serde_json::Value = resp.json().await.expect("json");
|
|
|
|
let profiles = body["profiles"].as_object().expect("profiles map");
|
|
assert_eq!(profiles.len(), 2, "expected 2 found profiles");
|
|
assert_eq!(
|
|
profiles[&hex_a.to_lowercase()]["display_name"].as_str(),
|
|
Some("Alice Batch")
|
|
);
|
|
assert_eq!(
|
|
profiles[&hex_b.to_lowercase()]["display_name"].as_str(),
|
|
Some("Bob Batch")
|
|
);
|
|
|
|
let missing = body["missing"].as_array().expect("missing array");
|
|
assert!(
|
|
missing
|
|
.iter()
|
|
.any(|v| v.as_str() == Some(&unknown_hex.to_lowercase())),
|
|
"unknown pubkey should be in missing"
|
|
);
|
|
}
|
|
|
|
/// POST /api/users/batch returns 400 when more than 200 pubkeys are submitted.
|
|
#[tokio::test]
|
|
#[ignore]
|
|
async fn test_batch_profiles_rejects_over_200() {
|
|
let client = http_client();
|
|
let keys = Keys::generate();
|
|
let pubkey_hex = keys.public_key().to_hex();
|
|
|
|
let pubkeys: Vec<String> = (0..201).map(|i| format!("{:064x}", i)).collect();
|
|
|
|
let resp = authed_post_json(
|
|
&client,
|
|
&format!("{}/api/users/batch", relay_http_url()),
|
|
&pubkey_hex,
|
|
serde_json::json!({"pubkeys": pubkeys}),
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(resp.status(), 400, "expected 400 for >200 pubkeys");
|
|
}
|
|
|
|
/// POST /api/users/batch returns 401 without authentication.
|
|
#[tokio::test]
|
|
#[ignore]
|
|
async fn test_batch_profiles_requires_auth() {
|
|
let client = http_client();
|
|
|
|
let resp = client
|
|
.post(format!("{}/api/users/batch", relay_http_url()))
|
|
.json(&serde_json::json!({"pubkeys": ["abc"]}))
|
|
.send()
|
|
.await
|
|
.expect("POST batch");
|
|
|
|
assert_eq!(resp.status(), 401, "expected 401 without auth");
|
|
}
|
|
|
|
/// POST /api/users/batch places invalid-length inputs in the missing list.
|
|
/// Also verifies that 64-char non-hex strings (e.g. "g" * 64) go to missing.
|
|
#[tokio::test]
|
|
#[ignore]
|
|
async fn test_batch_profiles_invalid_length_in_missing() {
|
|
let client = http_client();
|
|
let keys = Keys::generate();
|
|
let pubkey_hex = keys.public_key().to_hex();
|
|
|
|
let non_hex_64 = "g".repeat(64);
|
|
let resp = authed_post_json(
|
|
&client,
|
|
&format!("{}/api/users/batch", relay_http_url()),
|
|
&pubkey_hex,
|
|
serde_json::json!({"pubkeys": ["tooshort", "x".repeat(100), non_hex_64]}),
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(resp.status(), 200);
|
|
let body: serde_json::Value = resp.json().await.expect("json");
|
|
let missing = body["missing"].as_array().expect("missing");
|
|
assert_eq!(
|
|
missing.len(),
|
|
3,
|
|
"wrong-length and 64-char non-hex inputs should all be in missing"
|
|
);
|
|
|
|
let missing_strs: Vec<&str> = missing.iter().filter_map(|v| v.as_str()).collect();
|
|
assert!(
|
|
missing_strs.contains(&"tooshort"),
|
|
"short input should be in missing"
|
|
);
|
|
assert!(
|
|
missing_strs.iter().any(|s| s.len() == 100),
|
|
"too-long input should be in missing"
|
|
);
|
|
assert!(
|
|
missing_strs.contains(&"g".repeat(64).as_str()),
|
|
"64-char non-hex should be in missing"
|
|
);
|
|
}
|
|
|
|
/// POST /api/users/batch normalizes pubkey case before lookup.
|
|
#[tokio::test]
|
|
#[ignore]
|
|
async fn test_batch_profiles_case_normalized() {
|
|
let client = http_client();
|
|
let keys = Keys::generate();
|
|
let pubkey_hex = keys.public_key().to_hex();
|
|
|
|
// Set profile
|
|
client
|
|
.put(format!("{}/api/users/me/profile", relay_http_url()))
|
|
.header("X-Pubkey", &pubkey_hex)
|
|
.json(&serde_json::json!({"display_name": "Case Test"}))
|
|
.send()
|
|
.await
|
|
.expect("PUT");
|
|
|
|
// Query with uppercase version
|
|
let upper_hex = pubkey_hex.to_uppercase();
|
|
let resp = authed_post_json(
|
|
&client,
|
|
&format!("{}/api/users/batch", relay_http_url()),
|
|
&pubkey_hex,
|
|
serde_json::json!({"pubkeys": [upper_hex]}),
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(resp.status(), 200);
|
|
let body: serde_json::Value = resp.json().await.expect("json");
|
|
let profiles = body["profiles"].as_object().expect("profiles");
|
|
assert_eq!(profiles.len(), 1, "uppercase pubkey should match");
|
|
}
|
|
|
|
// ── NIP-05 tests ──────────────────────────────────────────────────────────────
|
|
|
|
/// GET /.well-known/nostr.json?name=nonexistent returns empty names and relays.
|
|
#[tokio::test]
|
|
#[ignore]
|
|
async fn test_nip05_returns_empty_for_unknown_name() {
|
|
let client = http_client();
|
|
|
|
let resp = client
|
|
.get(format!(
|
|
"{}/.well-known/nostr.json?name=nonexistent",
|
|
relay_http_url()
|
|
))
|
|
.send()
|
|
.await
|
|
.expect("GET nip05");
|
|
|
|
assert_eq!(resp.status(), 200);
|
|
let body: serde_json::Value = resp.json().await.expect("json");
|
|
assert_eq!(body["names"].as_object().unwrap().len(), 0);
|
|
assert_eq!(body["relays"].as_object().unwrap().len(), 0);
|
|
}
|
|
|
|
/// GET /.well-known/nostr.json with no name param returns empty names.
|
|
#[tokio::test]
|
|
#[ignore]
|
|
async fn test_nip05_no_name_returns_empty() {
|
|
let client = http_client();
|
|
|
|
let resp = client
|
|
.get(format!("{}/.well-known/nostr.json", relay_http_url()))
|
|
.send()
|
|
.await
|
|
.expect("GET nip05");
|
|
|
|
assert_eq!(resp.status(), 200);
|
|
let body: serde_json::Value = resp.json().await.expect("json");
|
|
assert_eq!(body["names"].as_object().unwrap().len(), 0);
|
|
}
|
|
|
|
/// GET /.well-known/nostr.json includes the required CORS header.
|
|
#[tokio::test]
|
|
#[ignore]
|
|
async fn test_nip05_has_cors_header() {
|
|
let client = http_client();
|
|
|
|
let resp = client
|
|
.get(format!("{}/.well-known/nostr.json", relay_http_url()))
|
|
.send()
|
|
.await
|
|
.expect("GET nip05");
|
|
|
|
assert_eq!(resp.status(), 200);
|
|
let cors = resp.headers().get("access-control-allow-origin");
|
|
assert!(
|
|
cors.is_some(),
|
|
"NIP-05 must have Access-Control-Allow-Origin header"
|
|
);
|
|
assert_eq!(cors.unwrap().to_str().unwrap(), "*");
|
|
}
|
|
|
|
/// Full round-trip: set nip05_handle via PUT, then verify via /.well-known/nostr.json.
|
|
#[tokio::test]
|
|
#[ignore]
|
|
async fn test_nip05_round_trip_set_and_lookup() {
|
|
let client = http_client();
|
|
let keys = Keys::generate();
|
|
let pubkey_hex = keys.public_key().to_hex();
|
|
|
|
// Set NIP-05 handle — use "testuser@localhost" since relay_url is ws://localhost:3000
|
|
let unique_name = format!("nip05test{}", &pubkey_hex[..8]);
|
|
let handle = format!("{}@localhost", unique_name);
|
|
let resp = authed_put(
|
|
&client,
|
|
&format!("{}/api/users/me/profile", relay_http_url()),
|
|
&pubkey_hex,
|
|
serde_json::json!({"nip05_handle": handle}),
|
|
)
|
|
.await;
|
|
assert_eq!(resp.status(), 200, "set nip05_handle should succeed");
|
|
|
|
// Query NIP-05 endpoint
|
|
let resp = client
|
|
.get(format!(
|
|
"{}/.well-known/nostr.json?name={}",
|
|
relay_http_url(),
|
|
unique_name
|
|
))
|
|
.send()
|
|
.await
|
|
.expect("nip05 request");
|
|
assert_eq!(resp.status(), 200);
|
|
|
|
let body: serde_json::Value = resp.json().await.expect("json");
|
|
let names = body["names"].as_object().expect("names map");
|
|
assert!(
|
|
names.contains_key(&unique_name),
|
|
"NIP-05 should resolve the name. Got: {:?}",
|
|
names
|
|
);
|
|
let resolved_pubkey = names[&unique_name].as_str().expect("pubkey string");
|
|
assert_eq!(
|
|
resolved_pubkey, pubkey_hex,
|
|
"NIP-05 resolved pubkey should match"
|
|
);
|
|
}
|
|
|
|
/// Setting nip05_handle to empty string clears it.
|
|
#[tokio::test]
|
|
#[ignore]
|
|
async fn test_nip05_clear_handle() {
|
|
let client = http_client();
|
|
let keys = Keys::generate();
|
|
let pubkey_hex = keys.public_key().to_hex();
|
|
|
|
let unique_name = format!("cleartest{}", &pubkey_hex[..8]);
|
|
let handle = format!("{}@localhost", unique_name);
|
|
|
|
// Set handle
|
|
let resp = authed_put(
|
|
&client,
|
|
&format!("{}/api/users/me/profile", relay_http_url()),
|
|
&pubkey_hex,
|
|
serde_json::json!({"nip05_handle": handle}),
|
|
)
|
|
.await;
|
|
assert_eq!(resp.status(), 200);
|
|
|
|
// Clear handle
|
|
let resp = authed_put(
|
|
&client,
|
|
&format!("{}/api/users/me/profile", relay_http_url()),
|
|
&pubkey_hex,
|
|
serde_json::json!({"nip05_handle": ""}),
|
|
)
|
|
.await;
|
|
assert_eq!(resp.status(), 200);
|
|
|
|
// Verify cleared — NIP-05 should no longer resolve
|
|
let resp = client
|
|
.get(format!(
|
|
"{}/.well-known/nostr.json?name={}",
|
|
relay_http_url(),
|
|
unique_name
|
|
))
|
|
.send()
|
|
.await
|
|
.expect("nip05 request");
|
|
assert_eq!(resp.status(), 200);
|
|
let body: serde_json::Value = resp.json().await.expect("json");
|
|
let names = body["names"].as_object().expect("names map");
|
|
assert!(
|
|
!names.contains_key(&unique_name),
|
|
"NIP-05 should NOT resolve after clearing. Got: {:?}",
|
|
names
|
|
);
|
|
}
|
|
|
|
/// Duplicate nip05_handle returns 409 Conflict.
|
|
#[tokio::test]
|
|
#[ignore]
|
|
async fn test_nip05_duplicate_handle_conflict() {
|
|
let client = http_client();
|
|
let keys_a = Keys::generate();
|
|
let pubkey_a = keys_a.public_key().to_hex();
|
|
let keys_b = Keys::generate();
|
|
let pubkey_b = keys_b.public_key().to_hex();
|
|
|
|
let unique_name = format!("duptest{}", &pubkey_a[..8]);
|
|
let handle = format!("{}@localhost", unique_name);
|
|
|
|
// User A sets handle
|
|
let resp = authed_put(
|
|
&client,
|
|
&format!("{}/api/users/me/profile", relay_http_url()),
|
|
&pubkey_a,
|
|
serde_json::json!({"nip05_handle": handle}),
|
|
)
|
|
.await;
|
|
assert_eq!(resp.status(), 200);
|
|
|
|
// User B tries same handle → 409
|
|
let resp = authed_put(
|
|
&client,
|
|
&format!("{}/api/users/me/profile", relay_http_url()),
|
|
&pubkey_b,
|
|
serde_json::json!({"nip05_handle": handle}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
resp.status(),
|
|
409,
|
|
"duplicate nip05_handle should return 409 Conflict"
|
|
);
|
|
}
|