use std::io::Write;
use std::sync::{Arc, Mutex};
use bingle_cli::chat::parse_chat_args;
use bingle_cli::chat_state::ChatState;
use bingle_local::api::bingle_local_api::{BingleLocalApi, ContactSource};
use bingle_local::api::bingle_local_api_impl::{BingleApiLocalImpl, LocalApiConfig};
use tempfile::TempDir;
fn args(items: &[&str]) -> Vec<String> {
items.iter().map(|s| s.to_string()).collect()
}
fn write_state_file(
dir: &TempDir,
handle: &str,
contact_handle: &str,
contact_id: &str,
) -> (String, String) {
let mut local = BingleApiLocalImpl::new(LocalApiConfig::default());
let keypair = local.generate_keypair().expect("generate keypair");
local.seed_own_handle_for_tests(handle.to_string());
local
.add_contact(
contact_handle.to_string(),
contact_id.to_string(),
ContactSource::Manual,
)
.expect("add contact");
let path = dir.path().join("state.json").to_string_lossy().into_owned();
local.save(&path).expect("save state");
(path, keypair.passphrase)
}
#[test]
#[cfg(not(target_os = "ios"))]
pub fn state_file_surfaces_keypair_handle_and_contacts() {
let dir = tempfile::tempdir().expect("tempdir");
let (path, passphrase) = write_state_file(&dir, "alice", "bob", "BOB_ID");
let chat_args = parse_chat_args(args(&["--state_file", &path])).expect("parse");
let state = ChatState::from_chat_args(&chat_args).expect("bridge should load");
assert_eq!(state.opts.handle, "alice");
assert_eq!(
state.opts.algo_passphrase.as_deref(),
Some(passphrase.as_str())
);
assert_eq!(state.resolve_recipient("bob"), Some("BOB_ID"));
assert!(state.resolve_recipient("carol").is_none());
}
#[test]
#[cfg(not(target_os = "ios"))]
pub fn cli_handle_and_passphrase_override_state_file() {
let dir = tempfile::tempdir().expect("tempdir");
let (path, _passphrase) = write_state_file(&dir, "alice", "bob", "BOB_ID");
let chat_args = parse_chat_args(args(&[
"--handle",
"override",
"--passphrase",
"cli-pass",
"--state_file",
&path,
]))
.expect("parse");
let state = ChatState::from_chat_args(&chat_args).expect("bridge should load");
assert_eq!(state.opts.handle, "override");
assert_eq!(state.opts.algo_passphrase.as_deref(), Some("cli-pass"));
}
#[test]
#[cfg(not(target_os = "ios"))]
pub fn save_state_round_trips_new_contact() {
let dir = tempfile::tempdir().expect("tempdir");
let (path, _passphrase) = write_state_file(&dir, "alice", "bob", "BOB_ID");
let chat_args = parse_chat_args(args(&["--state_file", &path])).expect("parse");
let mut state = ChatState::from_chat_args(&chat_args).expect("bridge should load");
state
.add_received_contact("carol", "CAROL_ID")
.expect("add contact");
state.save_state().expect("save state");
let chat_args2 = parse_chat_args(args(&["--state_file", &path])).expect("parse");
let reloaded = ChatState::from_chat_args(&chat_args2).expect("bridge should reload");
assert_eq!(reloaded.resolve_recipient("bob"), Some("BOB_ID"));
assert_eq!(reloaded.resolve_recipient("carol"), Some("CAROL_ID"));
}
#[test]
#[cfg(not(target_os = "ios"))]
pub fn missing_state_file_with_handle_starts_empty() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir
.path()
.join("does_not_exist.json")
.to_string_lossy()
.into_owned();
let chat_args =
parse_chat_args(args(&["--handle", "alice", "--state_file", &path])).expect("parse");
let state = ChatState::from_chat_args(&chat_args).expect("bridge should start empty");
assert_eq!(state.opts.handle, "alice");
assert!(state.contacts.is_empty());
}
#[test]
#[cfg(not(target_os = "ios"))]
pub fn missing_state_file_without_handle_defers_to_registration() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("nope.json").to_string_lossy().into_owned();
let chat_args = parse_chat_args(args(&["--state_file", &path])).expect("parse");
let state = ChatState::from_chat_args(&chat_args).expect("load should not require a handle");
assert_eq!(state.opts.handle, "");
assert!(state.contacts.is_empty());
}
#[test]
#[cfg(not(target_os = "ios"))]
pub fn malformed_state_file_is_clear_error_not_panic() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("bad.json").to_string_lossy().into_owned();
std::fs::write(&path, "{ this is not valid json ").expect("write bad file");
let chat_args =
parse_chat_args(args(&["--handle", "alice", "--state_file", &path])).expect("parse");
let err = ChatState::from_chat_args(&chat_args)
.err()
.expect("malformed json should error");
assert!(
err.contains("failed to load chat state"),
"error should point at the state load; got: {err}"
);
}
#[derive(Clone)]
struct CapturingWriter(Arc<Mutex<Vec<u8>>>);
impl Write for CapturingWriter {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0
.lock()
.expect("log buffer poisoned")
.extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for CapturingWriter {
type Writer = CapturingWriter;
fn make_writer(&'a self) -> Self::Writer {
self.clone()
}
}
#[test]
#[cfg(not(target_os = "ios"))]
pub fn bridge_never_logs_the_passphrase() {
let dir = tempfile::tempdir().expect("tempdir");
let (path, passphrase) = write_state_file(&dir, "alice", "bob", "BOB_ID");
let buffer = Arc::new(Mutex::new(Vec::<u8>::new()));
let subscriber = tracing_subscriber::fmt()
.with_max_level(tracing::Level::TRACE)
.with_writer(CapturingWriter(buffer.clone()))
.finish();
tracing::subscriber::with_default(subscriber, || {
let chat_args = parse_chat_args(args(&["--state_file", &path])).expect("parse");
let state = ChatState::from_chat_args(&chat_args).expect("bridge should load");
assert_eq!(
state.opts.algo_passphrase.as_deref(),
Some(passphrase.as_str())
);
});
let logs =
String::from_utf8(buffer.lock().expect("log buffer poisoned").clone()).expect("utf8 logs");
assert!(
!logs.contains(&passphrase),
"passphrase must never appear in logs"
);
}