use super::*;
use crate::config::Config;
use crate::output::Output;
use crate::storage::Storage;
use crate::storage::StoredChat;
use nostr::JsonUtil;
use std::sync::Once;
use tempfile::TempDir;
fn create_test_session() -> nostr_double_ratchet::Session {
let alice_keys = nostr::Keys::generate();
let bob_keys = nostr::Keys::generate();
let invite =
nostr_double_ratchet::Invite::create_new(alice_keys.public_key(), None, None).unwrap();
let bob_pk = bob_keys.public_key();
let (bob_session, _response) = invite
.accept_with_owner(
bob_pk,
bob_keys.secret_key().to_secret_bytes(),
None,
Some(bob_pk),
)
.unwrap();
bob_session
}
fn create_test_session_pair() -> (
nostr::Keys,
nostr::Keys,
nostr_double_ratchet::Session,
nostr_double_ratchet::Session,
) {
let alice_keys = nostr::Keys::generate();
let bob_keys = nostr::Keys::generate();
let invite =
nostr_double_ratchet::Invite::create_new(alice_keys.public_key(), None, None).unwrap();
let bob_pk = bob_keys.public_key();
let (bob_session, response) = invite
.accept_with_owner(
bob_pk,
bob_keys.secret_key().to_secret_bytes(),
None,
Some(bob_pk),
)
.unwrap();
let alice_session = invite
.process_invite_response(&response, alice_keys.secret_key().to_secret_bytes())
.unwrap()
.unwrap()
.session;
(alice_keys, bob_keys, bob_session, alice_session)
}
fn init_test_env() {
static INIT: Once = Once::new();
INIT.call_once(|| {
std::env::set_var("NDR_IGNORE_PUBLISH_ERRORS", "1");
std::env::set_var("NOSTR_PREFER_LOCAL", "0");
});
}
fn setup() -> (TempDir, Config, Storage, String) {
init_test_env();
let temp = TempDir::new().unwrap();
let mut config = Config::load(temp.path()).unwrap();
config
.set_private_key("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
.unwrap();
let config = Config::load(temp.path()).unwrap();
let storage = Storage::open(temp.path()).unwrap();
let session = create_test_session();
let session_state = serde_json::to_string(&session.state).unwrap();
let their_pubkey = nostr::Keys::generate().public_key().to_hex();
storage
.save_chat(&StoredChat {
id: "test-chat".to_string(),
their_pubkey,
device_id: None,
created_at: 1234567890,
last_message_at: None,
session_state: session_state.clone(),
message_ttl_seconds: None,
})
.unwrap();
(temp, config, storage, session_state)
}
#[tokio::test]
async fn test_send_message() {
init_test_env();
let (_temp, config, storage, _) = setup();
let output = Output::new(true);
send(
"test-chat",
"Hello!",
None,
None,
None,
&config,
&storage,
&output,
)
.await
.unwrap();
let messages = storage.get_messages("test-chat", 10).unwrap();
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].content, "Hello!");
assert!(messages[0].is_outgoing);
}
#[tokio::test]
async fn test_send_stores_inner_rumor_id_as_message_id() {
init_test_env();
let (_temp, config, storage, _) = setup();
let sent = super::send::send_message_impl(
"test-chat",
"Hello inner id!",
None,
None,
None,
&config,
&storage,
)
.await
.unwrap();
assert!(!sent.id.is_empty());
assert_eq!(sent.id, sent.inner_message_id);
assert!(!sent.event_ids.is_empty());
assert!(!sent.event_ids.contains(&sent.inner_message_id));
let messages = storage.get_messages("test-chat", 10).unwrap();
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].id, sent.inner_message_id);
assert_eq!(messages[0].content, "Hello inner id!");
assert!(messages[0].is_outgoing);
}
#[tokio::test]
async fn test_send_fans_out_to_all_known_recipient_devices_in_session_manager() {
init_test_env();
let temp = TempDir::new().unwrap();
let storage = Storage::open(temp.path()).unwrap();
let sender_keys = nostr::Keys::generate();
let our_device_id = sender_keys.public_key().to_hex();
let mut config = Config::load(temp.path()).unwrap();
config
.set_private_key(&sender_keys.secret_key().to_secret_hex())
.unwrap();
let config = Config::load(temp.path()).unwrap();
let output = Output::new(true);
let recipient_owner_keys = nostr::Keys::generate();
let recipient_device1_keys = nostr::Keys::generate();
let recipient_device2_keys = nostr::Keys::generate();
let recipient_owner = recipient_owner_keys.public_key();
let device1_id = recipient_device1_keys.public_key().to_hex();
let device2_id = recipient_device2_keys.public_key().to_hex();
let invite1 = nostr_double_ratchet::Invite::create_new(
recipient_device1_keys.public_key(),
Some(device1_id.clone()),
None,
)
.unwrap();
let (session1, _) = invite1
.accept_with_owner(
sender_keys.public_key(),
sender_keys.secret_key().to_secret_bytes(),
Some(our_device_id.clone()),
Some(sender_keys.public_key()),
)
.unwrap();
let invite2 = nostr_double_ratchet::Invite::create_new(
recipient_device2_keys.public_key(),
Some(device2_id.clone()),
None,
)
.unwrap();
let (session2, _) = invite2
.accept_with_owner(
sender_keys.public_key(),
sender_keys.secret_key().to_secret_bytes(),
Some(our_device_id.clone()),
Some(sender_keys.public_key()),
)
.unwrap();
storage
.save_chat(&StoredChat {
id: "peer-chat".to_string(),
their_pubkey: recipient_owner.to_hex(),
device_id: Some(device1_id.clone()),
created_at: 1234567890,
last_message_at: None,
session_state: serde_json::to_string(&session1.state).unwrap(),
message_ttl_seconds: None,
})
.unwrap();
{
let session_manager_store: std::sync::Arc<dyn nostr_double_ratchet::StorageAdapter> =
std::sync::Arc::new(
nostr_double_ratchet::FileStorageAdapter::new(
storage.data_dir().join("session_manager"),
)
.unwrap(),
);
let (sm_tx, _sm_rx) = crossbeam_channel::unbounded();
let manager = nostr_double_ratchet::SessionManager::new(
sender_keys.public_key(),
sender_keys.secret_key().to_secret_bytes(),
our_device_id.clone(),
sender_keys.public_key(),
sm_tx,
Some(session_manager_store),
None,
);
manager.init().unwrap();
manager
.import_session_state(
recipient_owner,
Some(device1_id.clone()),
session1.state.clone(),
)
.unwrap();
manager
.import_session_state(
recipient_owner,
Some(device2_id.clone()),
session2.state.clone(),
)
.unwrap();
let mut app_keys = nostr_double_ratchet::AppKeys::new(Vec::new());
app_keys.add_device(nostr_double_ratchet::DeviceEntry::new(
recipient_device1_keys.public_key(),
1,
));
app_keys.add_device(nostr_double_ratchet::DeviceEntry::new(
recipient_device2_keys.public_key(),
1,
));
let app_keys_event = app_keys
.get_event(recipient_owner)
.sign_with_keys(&recipient_owner_keys)
.unwrap();
manager.process_received_event(app_keys_event);
}
send(
"peer-chat",
"fanout",
None,
None,
None,
&config,
&storage,
&output,
)
.await
.unwrap();
let session_manager_store: std::sync::Arc<dyn nostr_double_ratchet::StorageAdapter> =
std::sync::Arc::new(
nostr_double_ratchet::FileStorageAdapter::new(
storage.data_dir().join("session_manager"),
)
.unwrap(),
);
let (sm_tx, _sm_rx) = crossbeam_channel::unbounded();
let manager = nostr_double_ratchet::SessionManager::new(
sender_keys.public_key(),
sender_keys.secret_key().to_secret_bytes(),
our_device_id,
sender_keys.public_key(),
sm_tx,
Some(session_manager_store),
None,
);
manager.init().unwrap();
let mut recipient_sends: std::collections::HashMap<String, u32> =
std::collections::HashMap::new();
for (owner, device_id, state) in manager.export_active_sessions() {
if owner == recipient_owner {
recipient_sends.insert(device_id, state.sending_chain_message_number);
}
}
assert_eq!(recipient_sends.get(&device1_id), Some(&1));
assert_eq!(recipient_sends.get(&device2_id), Some(&1));
}
#[tokio::test]
async fn test_send_message_with_ttl_adds_expiration_tag() {
init_test_env();
let temp = TempDir::new().unwrap();
let storage = Storage::open(temp.path()).unwrap();
let (alice_keys, bob_keys, bob_session, mut alice_session) = create_test_session_pair();
let mut config = Config::load(temp.path()).unwrap();
config
.set_private_key(&bob_keys.secret_key().to_secret_hex())
.unwrap();
let config = Config::load(temp.path()).unwrap();
let chat_id = "test-chat".to_string();
let session_state = serde_json::to_string(&bob_session.state).unwrap();
storage
.save_chat(&StoredChat {
id: chat_id.clone(),
their_pubkey: alice_keys.public_key().to_hex(),
device_id: None,
created_at: 1234567890,
last_message_at: None,
session_state,
message_ttl_seconds: None,
})
.unwrap();
let ttl_seconds = 60u64;
let prepared = super::send::prepare_send_message(
&chat_id,
"Hello expiring",
None,
Some(ttl_seconds),
None,
&config,
&storage,
)
.await
.unwrap();
assert_eq!(
prepared.stored_message.expires_at,
Some(prepared.timestamp + ttl_seconds)
);
let plaintext = alice_session
.receive(&prepared.encrypted_event)
.unwrap()
.unwrap();
let rumor = nostr::UnsignedEvent::from_json(&plaintext).unwrap();
let mut exp: Option<String> = None;
for t in rumor.tags.iter() {
let v = t.clone().to_vec();
if v.first().map(|s| s.as_str()) == Some(nostr_double_ratchet::EXPIRATION_TAG) {
exp = v.get(1).cloned();
}
}
let expected = prepared.stored_message.expires_at.unwrap().to_string();
assert_eq!(exp.as_deref(), Some(expected.as_str()));
}
#[tokio::test]
async fn test_chat_default_ttl_is_applied_when_sending_without_overrides() {
init_test_env();
let temp = TempDir::new().unwrap();
let storage = Storage::open(temp.path()).unwrap();
let (alice_keys, bob_keys, bob_session, mut alice_session) = create_test_session_pair();
let mut config = Config::load(temp.path()).unwrap();
config
.set_private_key(&bob_keys.secret_key().to_secret_hex())
.unwrap();
let config = Config::load(temp.path()).unwrap();
let chat_id = "test-chat".to_string();
let session_state = serde_json::to_string(&bob_session.state).unwrap();
let ttl_seconds = 90u64;
storage
.save_chat(&StoredChat {
id: chat_id.clone(),
their_pubkey: alice_keys.public_key().to_hex(),
device_id: None,
created_at: 1234567890,
last_message_at: None,
session_state,
message_ttl_seconds: Some(ttl_seconds),
})
.unwrap();
let prepared = super::send::prepare_send_message(
&chat_id,
"Hello default expiring",
None,
None,
None,
&config,
&storage,
)
.await
.unwrap();
assert_eq!(
prepared.stored_message.expires_at,
Some(prepared.timestamp + ttl_seconds)
);
let plaintext = alice_session
.receive(&prepared.encrypted_event)
.unwrap()
.unwrap();
let rumor = nostr::UnsignedEvent::from_json(&plaintext).unwrap();
let mut exp: Option<String> = None;
for t in rumor.tags.iter() {
let v = t.clone().to_vec();
if v.first().map(|s| s.as_str()) == Some(nostr_double_ratchet::EXPIRATION_TAG) {
exp = v.get(1).cloned();
}
}
let expected = prepared.stored_message.expires_at.unwrap().to_string();
assert_eq!(exp.as_deref(), Some(expected.as_str()));
}
#[tokio::test]
async fn test_read_messages() {
let (_temp, config, storage, _) = setup();
let output = Output::new(true);
send(
"test-chat",
"One",
None,
None,
None,
&config,
&storage,
&output,
)
.await
.unwrap();
send(
"test-chat",
"Two",
None,
None,
None,
&config,
&storage,
&output,
)
.await
.unwrap();
read("test-chat", 10, &storage, &output).await.unwrap();
}
#[tokio::test]
async fn test_send_updates_last_message_at() {
let (_temp, config, storage, _) = setup();
let output = Output::new(true);
let before = storage.get_chat("test-chat").unwrap().unwrap();
assert!(before.last_message_at.is_none());
send(
"test-chat",
"Hello!",
None,
None,
None,
&config,
&storage,
&output,
)
.await
.unwrap();
let after = storage.get_chat("test-chat").unwrap().unwrap();
assert!(after.last_message_at.is_some());
}
#[tokio::test]
async fn test_send_chat_id_routes_by_pubkey_not_selected_chat_id() {
init_test_env();
let temp = TempDir::new().unwrap();
let storage = Storage::open(temp.path()).unwrap();
let output = Output::new(true);
let mut config = Config::load(temp.path()).unwrap();
config
.set_private_key("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
.unwrap();
let config = Config::load(temp.path()).unwrap();
let session = create_test_session();
let session_state = serde_json::to_string(&session.state).unwrap();
let peer_pubkey = nostr::Keys::generate().public_key().to_hex();
storage
.save_chat(&StoredChat {
id: "old-chat".to_string(),
their_pubkey: peer_pubkey.clone(),
device_id: Some("device-old".to_string()),
created_at: 1000,
last_message_at: Some(1000),
session_state: session_state.clone(),
message_ttl_seconds: None,
})
.unwrap();
storage
.save_chat(&StoredChat {
id: "new-chat".to_string(),
their_pubkey: peer_pubkey,
device_id: Some("device-new".to_string()),
created_at: 1000,
last_message_at: Some(5000),
session_state,
message_ttl_seconds: None,
})
.unwrap();
send(
"old-chat",
"route by pubkey",
None,
None,
None,
&config,
&storage,
&output,
)
.await
.unwrap();
let old_messages = storage.get_messages("old-chat", 10).unwrap();
let new_messages = storage.get_messages("new-chat", 10).unwrap();
assert!(old_messages.is_empty());
assert_eq!(new_messages.len(), 1);
assert_eq!(new_messages[0].content, "route by pubkey");
}
#[tokio::test]
async fn test_receive_typing_does_not_save_message() {
init_test_env();
let temp = TempDir::new().unwrap();
let storage = Storage::open(temp.path()).unwrap();
let output = Output::new(true);
let (_alice_keys, bob_keys, mut bob_session, alice_session) = create_test_session_pair();
let chat_id = "peer-chat".to_string();
let session_state = serde_json::to_string(&alice_session.state).unwrap();
storage
.save_chat(&StoredChat {
id: chat_id.clone(),
their_pubkey: bob_keys.public_key().to_hex(),
device_id: None,
created_at: 1234567890,
last_message_at: None,
session_state,
message_ttl_seconds: None,
})
.unwrap();
let typing_event = bob_session.send_typing().unwrap();
super::receive::receive(&typing_event.as_json(), &storage, &output)
.await
.unwrap();
let messages = storage.get_messages(&chat_id, 10).unwrap();
assert_eq!(messages.len(), 0);
let chat = storage.get_chat(&chat_id).unwrap().unwrap();
assert!(chat.last_message_at.is_none());
}
#[test]
fn test_resolve_target_by_chat_id() {
let (_temp, _config, storage, _) = setup();
let chat = resolve_target("test-chat", &storage).unwrap();
assert_eq!(chat.id, "test-chat");
}
#[test]
fn test_resolve_target_by_hex_pubkey() {
let (_temp, _config, storage, _) = setup();
let keys = nostr::Keys::generate();
let pubkey_hex = keys.public_key().to_hex();
let session = create_test_session();
let session_state = serde_json::to_string(&session.state).unwrap();
storage
.save_chat(&StoredChat {
id: "pk-chat".to_string(),
their_pubkey: pubkey_hex.clone(),
device_id: None,
created_at: 1234567890,
last_message_at: None,
session_state,
message_ttl_seconds: None,
})
.unwrap();
let chat = resolve_target(&pubkey_hex, &storage).unwrap();
assert_eq!(chat.id, "pk-chat");
}
#[test]
fn test_resolve_target_by_npub() {
let (_temp, _config, storage, _) = setup();
let keys = nostr::Keys::generate();
let pubkey_hex = keys.public_key().to_hex();
let npub = nostr::ToBech32::to_bech32(&keys.public_key()).unwrap();
let session = create_test_session();
let session_state = serde_json::to_string(&session.state).unwrap();
storage
.save_chat(&StoredChat {
id: "npub-chat".to_string(),
their_pubkey: pubkey_hex,
device_id: None,
created_at: 1234567890,
last_message_at: None,
session_state,
message_ttl_seconds: None,
})
.unwrap();
let chat = resolve_target(&npub, &storage).unwrap();
assert_eq!(chat.id, "npub-chat");
}
#[test]
fn test_resolve_target_by_chat_link_hash_npub() {
let (_temp, _config, storage, _) = setup();
let keys = nostr::Keys::generate();
let pubkey_hex = keys.public_key().to_hex();
let npub = nostr::ToBech32::to_bech32(&keys.public_key()).unwrap();
let session = create_test_session();
let session_state = serde_json::to_string(&session.state).unwrap();
storage
.save_chat(&StoredChat {
id: "link-chat".to_string(),
their_pubkey: pubkey_hex.clone(),
device_id: None,
created_at: 1234567890,
last_message_at: None,
session_state,
message_ttl_seconds: None,
})
.unwrap();
let link = format!("https://chat.iris.to/#{}", npub);
let chat = resolve_target(&link, &storage).unwrap();
assert_eq!(chat.id, "link-chat");
let link_slash = format!("https://chat.iris.to/#/{}", npub);
let chat = resolve_target(&link_slash, &storage).unwrap();
assert_eq!(chat.id, "link-chat");
}
#[test]
fn test_resolve_target_pubkey_accepts_chat_link_hash_npub() {
let (_temp, _config, storage, _) = setup();
let keys = nostr::Keys::generate();
let pubkey_hex = keys.public_key().to_hex();
let npub = nostr::ToBech32::to_bech32(&keys.public_key()).unwrap();
let link = format!("https://chat.iris.to/#{}", npub);
let resolved = super::common::resolve_target_pubkey(&link, &storage).unwrap();
assert_eq!(resolved, pubkey_hex);
let nostr_uri = format!("nostr:{}", npub);
let resolved = super::common::resolve_target_pubkey(&nostr_uri, &storage).unwrap();
assert_eq!(resolved, pubkey_hex);
}
#[test]
fn test_resolve_target_not_found() {
let (_temp, _config, storage, _) = setup();
assert!(resolve_target("nonexistent", &storage).is_err());
}
#[test]
fn test_resolve_target_prefers_recent() {
let (_temp, _config, storage, _) = setup();
let keys = nostr::Keys::generate();
let pubkey_hex = keys.public_key().to_hex();
let session1 = create_test_session();
let session2 = create_test_session();
storage
.save_chat(&StoredChat {
id: "old-chat".to_string(),
their_pubkey: pubkey_hex.clone(),
device_id: None,
created_at: 1000,
last_message_at: Some(2000),
session_state: serde_json::to_string(&session1.state).unwrap(),
message_ttl_seconds: None,
})
.unwrap();
storage
.save_chat(&StoredChat {
id: "new-chat".to_string(),
their_pubkey: pubkey_hex.clone(),
device_id: None,
created_at: 1000,
last_message_at: Some(5000),
session_state: serde_json::to_string(&session2.state).unwrap(),
message_ttl_seconds: None,
})
.unwrap();
let chat = resolve_target(&pubkey_hex, &storage).unwrap();
assert_eq!(chat.id, "new-chat");
}
#[test]
fn test_resolve_target_by_petname() {
let (_temp, _config, storage, _) = setup();
let keys = nostr::Keys::generate();
let pubkey_hex = keys.public_key().to_hex();
let npub = nostr::ToBech32::to_bech32(&keys.public_key()).unwrap();
let session = create_test_session();
let session_state = serde_json::to_string(&session.state).unwrap();
storage
.save_chat(&StoredChat {
id: "pet-chat".to_string(),
their_pubkey: pubkey_hex,
device_id: None,
created_at: 1234567890,
last_message_at: None,
session_state,
message_ttl_seconds: None,
})
.unwrap();
storage.add_contact(&npub, "alice").unwrap();
let chat = resolve_target("alice", &storage).unwrap();
assert_eq!(chat.id, "pet-chat");
}
fn build_rumor_json(
sender_owner_hex: &str,
kind: u32,
content: &str,
tags: Vec<Vec<String>>,
) -> String {
serde_json::json!({
"id": uuid::Uuid::new_v4().to_string(),
"pubkey": sender_owner_hex,
"created_at": 0,
"kind": kind,
"tags": tags,
"content": content,
})
.to_string()
}
#[test]
fn test_session_manager_decrypted_incoming_from_same_owner_routes_to_single_chat() {
init_test_env();
let temp = TempDir::new().unwrap();
let mut config = Config::load(temp.path()).unwrap();
let me = nostr::Keys::generate();
config
.set_private_key(&me.secret_key().to_secret_hex())
.unwrap();
let config = Config::load(temp.path()).unwrap();
let output = Output::new(true);
let storage = Storage::open(temp.path()).unwrap();
let peer_owner = nostr::Keys::generate().public_key().to_hex();
storage
.save_chat(&StoredChat {
id: "peer-chat".to_string(),
their_pubkey: peer_owner.clone(),
device_id: None,
created_at: 1000,
last_message_at: None,
session_state: "{}".to_string(),
message_ttl_seconds: None,
})
.unwrap();
let rumor1 = build_rumor_json(&peer_owner, 14, "hi from peer device 1", vec![]);
let rumor2 = build_rumor_json(&peer_owner, 14, "hi from peer device 2", vec![]);
let handled1 = super::listen::apply_session_manager_one_to_one_decrypted(
nostr::PublicKey::from_hex(&peer_owner).unwrap(),
&rumor1,
Some("outer-1"),
2000,
&config,
&storage,
&output,
)
.unwrap();
let handled2 = super::listen::apply_session_manager_one_to_one_decrypted(
nostr::PublicKey::from_hex(&peer_owner).unwrap(),
&rumor2,
Some("outer-2"),
2001,
&config,
&storage,
&output,
)
.unwrap();
assert!(handled1);
assert!(handled2);
let messages = storage.get_messages("peer-chat", 10).unwrap();
assert_eq!(messages.len(), 2);
assert_eq!(messages[0].chat_id, "peer-chat");
assert_eq!(messages[1].chat_id, "peer-chat");
assert!(!messages[0].is_outgoing);
assert!(!messages[1].is_outgoing);
}
#[test]
fn test_session_manager_decrypted_sibling_copy_routes_to_peer_chat_as_outgoing() {
init_test_env();
let temp = TempDir::new().unwrap();
let mut config = Config::load(temp.path()).unwrap();
let me = nostr::Keys::generate();
let my_owner_hex = me.public_key().to_hex();
config
.set_private_key(&me.secret_key().to_secret_hex())
.unwrap();
let config = Config::load(temp.path()).unwrap();
let output = Output::new(true);
let storage = Storage::open(temp.path()).unwrap();
let peer_owner = nostr::Keys::generate().public_key().to_hex();
storage
.save_chat(&StoredChat {
id: "peer-chat".to_string(),
their_pubkey: peer_owner.clone(),
device_id: None,
created_at: 1000,
last_message_at: None,
session_state: "{}".to_string(),
message_ttl_seconds: None,
})
.unwrap();
storage
.save_chat(&StoredChat {
id: "self-chat".to_string(),
their_pubkey: my_owner_hex.clone(),
device_id: Some("my-other-device".to_string()),
created_at: 1000,
last_message_at: None,
session_state: "{}".to_string(),
message_ttl_seconds: None,
})
.unwrap();
let rumor = build_rumor_json(
&my_owner_hex,
14,
"hello from sibling device",
vec![vec!["p".to_string(), peer_owner.clone()]],
);
let handled = super::listen::apply_session_manager_one_to_one_decrypted(
nostr::PublicKey::from_hex(&my_owner_hex).unwrap(),
&rumor,
Some("outer-self-1"),
3000,
&config,
&storage,
&output,
)
.unwrap();
assert!(handled);
let peer_messages = storage.get_messages("peer-chat", 10).unwrap();
assert_eq!(peer_messages.len(), 1);
assert!(peer_messages[0].is_outgoing);
assert_eq!(peer_messages[0].chat_id, "peer-chat");
let self_messages = storage.get_messages("self-chat", 10).unwrap();
assert!(self_messages.is_empty());
}