use std::net::SocketAddr;
use std::sync::Arc;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpListener as TokioTcpListener;
use tokio::sync::broadcast;
use tower_http::cors::CorsLayer;
async fn start_mock_signal_cli() -> SocketAddr {
let listener = TokioTcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
let (stream, _) = listener.accept().await.unwrap();
tokio::spawn(async move {
let (reader, mut writer) = stream.into_split();
let mut lines = BufReader::new(reader).lines();
while let Ok(Some(line)) = lines.next_line().await {
let req: serde_json::Value = match serde_json::from_str(&line) {
Ok(v) => v,
Err(_) => continue,
};
let id = req["id"].clone();
let method = req["method"].as_str().unwrap_or("");
let params = req.get("params");
let is_error = method == "simulateError"
|| params
.and_then(|p| p.get("account"))
.and_then(|a| a.as_str())
== Some("+ERROR")
|| params
.and_then(|p| p.get("number"))
.and_then(|a| a.as_str())
== Some("+ERROR");
if is_error {
let response = serde_json::json!({
"jsonrpc": "2.0",
"error": {"code": -32000, "message": "simulated signal-cli error"},
"id": id
});
let mut resp_line = serde_json::to_string(&response).unwrap();
resp_line.push('\n');
let _ = writer.write_all(resp_line.as_bytes()).await;
let _ = writer.flush().await;
continue;
}
let result = match method {
"send" => serde_json::json!({"timestamp": 1234567890}),
"remoteDelete" => serde_json::json!({}),
"listGroups" => {
serde_json::json!([{"id": "g1", "name": "Test Group", "members": ["+1111"]}])
}
"updateGroup" => serde_json::json!({"groupId": "g1"}),
"quitGroup" => serde_json::json!({}),
"joinGroup" => serde_json::json!({}),
"block" => serde_json::json!({}),
"listContacts" => {
serde_json::json!([{"number": "+1111", "name": "Alice"}])
}
"updateContact" => serde_json::json!({}),
"sendContacts" => serde_json::json!({}),
"updateProfile" => serde_json::json!({}),
"listIdentities" => {
serde_json::json!([{"number": "+1111", "status": "TRUSTED"}])
}
"trust" => serde_json::json!({}),
"listAccounts" => serde_json::json!(["+1234567890"]),
"register" => serde_json::json!({}),
"verify" => serde_json::json!({}),
"unregister" => serde_json::json!({}),
"submitRateLimitChallenge" => serde_json::json!({}),
"updateAccountSettings" => serde_json::json!({}),
"setPin" => serde_json::json!({}),
"removePin" => serde_json::json!({}),
"setUsername" => serde_json::json!({}),
"removeUsername" => serde_json::json!({}),
"listDevices" => {
serde_json::json!([{"id": 1, "name": "Desktop"}])
}
"startLink" => {
serde_json::json!({"deviceLinkUri": "sgnl://linkdevice?uuid=test&pub_key=abc"})
}
"finishLink" => serde_json::json!({}),
"removeDevice" => serde_json::json!({}),
"deleteLocalAccountData" => serde_json::json!({}),
"sendTyping" => serde_json::json!({}),
"sendReaction" => serde_json::json!({"timestamp": 1234567890}),
"removeReaction" => serde_json::json!({}),
"sendReceipt" => serde_json::json!({}),
"getUserStatus" => {
serde_json::json!([{"number": "+1111", "registered": true}])
}
"listStickerPacks" => {
serde_json::json!([{"packId": "sp1", "title": "Cool Pack"}])
}
"uploadStickerPack" => serde_json::json!({"packId": "sp2"}),
"sendPoll" => serde_json::json!({"timestamp": 1234567890}),
"sendPollVote" => serde_json::json!({}),
"closePoll" => serde_json::json!({}),
"listAttachments" => {
serde_json::json!([{"id": "att1", "filename": "photo.jpg"}])
}
"getAttachment" => {
serde_json::json!({"id": "att1", "filename": "photo.jpg", "size": 12345})
}
"deleteAttachment" => serde_json::json!({}),
"getConfiguration" => serde_json::json!({"trustMode": "always"}),
"setConfiguration" => serde_json::json!({}),
"getAccountSettings" => {
serde_json::json!({"trustMode": "on-first-use"})
}
"setAccountSettings" => serde_json::json!({}),
_ => serde_json::json!({}),
};
let response =
serde_json::json!({"jsonrpc": "2.0", "result": result, "id": id});
let mut resp_line = serde_json::to_string(&response).unwrap();
resp_line.push('\n');
let _ = writer.write_all(resp_line.as_bytes()).await;
let _ = writer.flush().await;
}
});
}
});
addr
}
struct TestHarness {
base_url: String,
broadcast_tx: broadcast::Sender<String>,
metrics: Arc<signal_cli_api::state::Metrics>,
}
async fn setup_full() -> TestHarness {
let mock_addr = start_mock_signal_cli().await;
let stream = tokio::net::TcpStream::connect(mock_addr).await.unwrap();
let (reader, writer) = stream.into_split();
let (writer_tx, writer_rx) = tokio::sync::mpsc::channel::<String>(256);
tokio::spawn(signal_cli_api::jsonrpc::writer_loop(writer_rx, writer));
let state = signal_cli_api::state::AppState::new(writer_tx);
let broadcast_tx = state.broadcast_tx.clone();
let pending = state.pending.clone();
let metrics = state.metrics.clone();
tokio::spawn(signal_cli_api::jsonrpc::reader_loop(
reader,
broadcast_tx.clone(),
pending,
metrics.clone(),
));
let webhook_state = state.clone();
tokio::spawn(signal_cli_api::webhooks::dispatch_loop(webhook_state));
let app = signal_cli_api::routes::router(state).layer(CorsLayer::permissive());
let listener = TokioTcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
TestHarness {
base_url: format!("http://{addr}"),
broadcast_tx,
metrics,
}
}
async fn setup() -> String {
setup_full().await.base_url
}
async fn assert_get(base: &str, path: &str, status: u16) -> Option<serde_json::Value> {
let res = reqwest::get(format!("{base}{path}")).await.unwrap();
assert_eq!(res.status(), status, "GET {path} expected {status}, got {}", res.status());
if status == 204 { return None; }
res.json().await.ok()
}
async fn assert_json_request(
base: &str,
method: &str,
path: &str,
body: serde_json::Value,
status: u16,
) -> Option<serde_json::Value> {
let client = reqwest::Client::new();
let res = match method {
"POST" => client.post(format!("{base}{path}")).json(&body).send().await.unwrap(),
"PUT" => client.put(format!("{base}{path}")).json(&body).send().await.unwrap(),
"DELETE" => client.delete(format!("{base}{path}")).json(&body).send().await.unwrap(),
_ => panic!("unsupported method: {method}"),
};
assert_eq!(res.status(), status, "{method} {path} expected {status}, got {}", res.status());
if status == 204 { return None; }
res.json().await.ok()
}
async fn assert_no_body_request(
base: &str,
method: &str,
path: &str,
status: u16,
) -> Option<serde_json::Value> {
let client = reqwest::Client::new();
let res = match method {
"POST" => client.post(format!("{base}{path}")).send().await.unwrap(),
"DELETE" => client.delete(format!("{base}{path}")).send().await.unwrap(),
_ => panic!("unsupported method: {method}"),
};
assert_eq!(res.status(), status, "{method} {path} expected {status}, got {}", res.status());
if status == 204 { return None; }
res.json().await.ok()
}
#[tokio::test]
async fn test_health() {
let base = setup().await;
assert_get(&base, "/v1/health", 204).await;
}
#[tokio::test]
async fn test_about() {
let base = setup().await;
let res = reqwest::get(format!("{base}/v1/about")).await.unwrap();
assert_eq!(res.status(), 200);
let body: serde_json::Value = res.json().await.unwrap();
assert!(body.get("versions").is_some());
assert!(body["versions"].get("signal-cli-api").is_some());
assert!(body.get("build").is_some());
assert!(body["build"].get("os").is_some());
assert!(body["build"].get("target").is_some());
}
#[tokio::test]
async fn test_send_v2() {
let base = setup().await;
let body = assert_json_request(&base, "POST", "/v2/send", serde_json::json!({"message": "hello", "number": "+1234567890", "recipients": ["+9999"]}), 201).await;
assert_eq!(body.unwrap()["timestamp"], 1234567890);
}
#[tokio::test]
async fn test_send_v1_deprecated() {
let base = setup().await;
let body = assert_json_request(&base, "POST", "/v1/send", serde_json::json!({"message": "hello", "number": "+1234567890", "recipients": ["+9999"]}), 201).await;
assert_eq!(body.unwrap()["timestamp"], 1234567890);
}
#[tokio::test]
async fn test_send_v2_with_attachments() {
let base = setup().await;
assert_json_request(&base, "POST", "/v2/send", serde_json::json!({"message": "look at this", "number": "+1234567890", "recipients": ["+9999"], "base64_attachments": ["aGVsbG8="]}), 201).await;
}
#[tokio::test]
async fn test_send_v2_empty_message() {
let base = setup().await;
assert_json_request(&base, "POST", "/v2/send", serde_json::json!({"message": "", "number": "+1234567890", "recipients": ["+9999"]}), 201).await;
}
#[tokio::test]
async fn test_send_v2_multiple_recipients() {
let base = setup().await;
assert_json_request(&base, "POST", "/v2/send", serde_json::json!({"message": "broadcast", "number": "+1234567890", "recipients": ["+1111", "+2222", "+3333"]}), 201).await;
}
#[tokio::test]
async fn test_send_v2_unicode_message() {
let base = setup().await;
assert_json_request(&base, "POST", "/v2/send", serde_json::json!({"message": "Hello 🌍🔥 Привет мир こんにちは", "number": "+1234567890", "recipients": ["+9999"]}), 201).await;
}
#[tokio::test]
async fn test_remote_delete() {
let base = setup().await;
assert_json_request(&base, "DELETE", "/v1/remote-delete/+123", serde_json::json!({"recipient": "+9999", "timestamp": 12345}), 200).await;
}
#[tokio::test]
async fn test_typing_start() {
let base = setup().await;
assert_json_request(&base, "PUT", "/v1/typing-indicator/+123", serde_json::json!({"recipient": "+9999"}), 204).await;
}
#[tokio::test]
async fn test_typing_stop() {
let base = setup().await;
assert_json_request(&base, "DELETE", "/v1/typing-indicator/+123", serde_json::json!({"recipient": "+9999"}), 204).await;
}
#[tokio::test]
async fn test_typing_to_group() {
let base = setup().await;
assert_json_request(&base, "PUT", "/v1/typing-indicator/+123", serde_json::json!({"recipient": "+9999", "group-id": "g1"}), 204).await;
}
#[tokio::test]
async fn test_reaction_send() {
let base = setup().await;
let body = assert_json_request(&base, "POST", "/v1/reactions/+123", serde_json::json!({"recipient": "+9999", "reaction": "👍", "target_author": "+9999", "timestamp": 12345}), 201).await;
assert_eq!(body.unwrap()["timestamp"], 1234567890);
}
#[tokio::test]
async fn test_reaction_remove() {
let base = setup().await;
assert_json_request(&base, "DELETE", "/v1/reactions/+123", serde_json::json!({"recipient": "+9999", "reaction": "👍", "target_author": "+9999", "timestamp": 12345}), 204).await;
}
#[tokio::test]
async fn test_reaction_emoji_variety() {
let base = setup().await;
let client = reqwest::Client::new();
for emoji in &["❤️", "😂", "🎉", "😢", "🤔"] {
let res = client
.post(format!("{base}/v1/reactions/+123"))
.json(&serde_json::json!({
"recipient": "+9999",
"reaction": emoji,
"target_author": "+9999",
"timestamp": 12345
}))
.send()
.await
.unwrap();
assert_eq!(res.status(), 201, "Failed for emoji {emoji}");
}
}
#[tokio::test]
async fn test_receipt_read() {
let base = setup().await;
assert_json_request(&base, "POST", "/v1/receipts/+123", serde_json::json!({"receipt_type": "read", "recipient": "+9999", "timestamp": 12345}), 200).await;
}
#[tokio::test]
async fn test_receipt_delivery() {
let base = setup().await;
assert_json_request(&base, "POST", "/v1/receipts/+123", serde_json::json!({"receipt_type": "delivery", "recipient": "+9999", "timestamp": 12345}), 200).await;
}
#[tokio::test]
async fn test_groups_list() {
let base = setup().await;
let body = assert_get(&base, "/v1/groups/+123", 200).await.unwrap();
let groups = body.as_array().unwrap();
assert!(!groups.is_empty());
assert_eq!(groups[0]["name"], "Test Group");
}
#[tokio::test]
async fn test_groups_get_single() {
let base = setup().await;
assert_get(&base, "/v1/groups/+123/g1", 200).await;
}
#[tokio::test]
async fn test_groups_create() {
let base = setup().await;
let body = assert_json_request(&base, "POST", "/v1/groups/+123", serde_json::json!({"name": "New Group", "members": ["+9999"]}), 201).await;
assert!(body.unwrap().get("groupId").is_some());
}
#[tokio::test]
async fn test_groups_create_with_description() {
let base = setup().await;
assert_json_request(&base, "POST", "/v1/groups/+123", serde_json::json!({"name": "Described Group", "members": ["+9999"], "description": "A test group with description"}), 201).await;
}
#[tokio::test]
async fn test_groups_create_with_permissions() {
let base = setup().await;
assert_json_request(&base, "POST", "/v1/groups/+123", serde_json::json!({"name": "Restricted Group", "members": ["+9999"], "permissions": {"add_members": "only-admins", "edit_details": "only-admins"}}), 201).await;
}
#[tokio::test]
async fn test_groups_update() {
let base = setup().await;
assert_json_request(&base, "PUT", "/v1/groups/+123/g1", serde_json::json!({"name": "Renamed Group"}), 200).await;
}
#[tokio::test]
async fn test_groups_update_description() {
let base = setup().await;
assert_json_request(&base, "PUT", "/v1/groups/+123/g1", serde_json::json!({"description": "Updated description"}), 200).await;
}
#[tokio::test]
async fn test_groups_update_expiration() {
let base = setup().await;
assert_json_request(&base, "PUT", "/v1/groups/+123/g1", serde_json::json!({"expiration": 86400}), 200).await;
}
#[tokio::test]
async fn test_groups_delete() {
let base = setup().await;
assert_no_body_request(&base, "DELETE", "/v1/groups/+123/g1", 200).await;
}
#[tokio::test]
async fn test_groups_add_members() {
let base = setup().await;
assert_json_request(&base, "POST", "/v1/groups/+123/g1/members", serde_json::json!({"members": ["+2222", "+3333"]}), 200).await;
}
#[tokio::test]
async fn test_groups_remove_members() {
let base = setup().await;
assert_json_request(&base, "DELETE", "/v1/groups/+123/g1/members", serde_json::json!({"members": ["+2222"]}), 200).await;
}
#[tokio::test]
async fn test_groups_add_admins() {
let base = setup().await;
assert_json_request(&base, "POST", "/v1/groups/+123/g1/admins", serde_json::json!({"admins": ["+2222"]}), 200).await;
}
#[tokio::test]
async fn test_groups_remove_admins() {
let base = setup().await;
assert_json_request(&base, "DELETE", "/v1/groups/+123/g1/admins", serde_json::json!({"admins": ["+2222"]}), 200).await;
}
#[tokio::test]
async fn test_groups_join() {
let base = setup().await;
assert_no_body_request(&base, "POST", "/v1/groups/+123/g1/join", 200).await;
}
#[tokio::test]
async fn test_groups_quit() {
let base = setup().await;
assert_no_body_request(&base, "POST", "/v1/groups/+123/g1/quit", 200).await;
}
#[tokio::test]
async fn test_groups_block() {
let base = setup().await;
assert_no_body_request(&base, "POST", "/v1/groups/+123/g1/block", 200).await;
}
#[tokio::test]
async fn test_groups_avatar_not_implemented() {
let base = setup().await;
let body = assert_get(&base, "/v1/groups/+123/g1/avatar", 501).await.unwrap();
assert!(body.get("error").is_some());
}
#[tokio::test]
async fn test_contacts_list() {
let base = setup().await;
let body = assert_get(&base, "/v1/contacts/+123", 200).await.unwrap();
let contacts = body.as_array().unwrap();
assert!(!contacts.is_empty());
assert_eq!(contacts[0]["name"], "Alice");
}
#[tokio::test]
async fn test_contacts_get_single() {
let base = setup().await;
assert_get(&base, "/v1/contacts/+123/+1111", 200).await;
}
#[tokio::test]
async fn test_contacts_update() {
let base = setup().await;
assert_json_request(&base, "PUT", "/v1/contacts/+123", serde_json::json!({"name": "Bob", "recipient": "+9999"}), 200).await;
}
#[tokio::test]
async fn test_contacts_update_with_expiration() {
let base = setup().await;
assert_json_request(&base, "PUT", "/v1/contacts/+123", serde_json::json!({"name": "Bob", "recipient": "+9999", "expiration": 3600}), 200).await;
}
#[tokio::test]
async fn test_contacts_sync() {
let base = setup().await;
assert_no_body_request(&base, "POST", "/v1/contacts/+123/sync", 200).await;
}
#[tokio::test]
async fn test_contacts_avatar_not_implemented() {
let base = setup().await;
let body = assert_get(&base, "/v1/contacts/+123/+1111/avatar", 501).await.unwrap();
assert!(body.get("error").is_some());
}
#[tokio::test]
async fn test_profiles_update() {
let base = setup().await;
assert_json_request(&base, "PUT", "/v1/profiles/+123", serde_json::json!({"name": "My Name"}), 200).await;
}
#[tokio::test]
async fn test_profiles_update_with_about() {
let base = setup().await;
assert_json_request(&base, "PUT", "/v1/profiles/+123", serde_json::json!({"name": "My Name", "about": "Security researcher"}), 200).await;
}
#[tokio::test]
async fn test_profiles_update_with_avatar() {
let base = setup().await;
assert_json_request(&base, "PUT", "/v1/profiles/+123", serde_json::json!({"name": "My Name", "base64_avatar": "aGVsbG8="}), 200).await;
}
#[tokio::test]
async fn test_identities_list() {
let base = setup().await;
let body = assert_get(&base, "/v1/identities/+123", 200).await.unwrap();
let identities = body.as_array().unwrap();
assert!(!identities.is_empty());
assert_eq!(identities[0]["status"], "TRUSTED");
}
#[tokio::test]
async fn test_identities_trust_all_known_keys() {
let base = setup().await;
assert_json_request(&base, "PUT", "/v1/identities/+123/trust/+9999", serde_json::json!({"trust_all_known_keys": true}), 200).await;
}
#[tokio::test]
async fn test_identities_trust_verified_safety_number() {
let base = setup().await;
assert_json_request(&base, "PUT", "/v1/identities/+123/trust/+9999", serde_json::json!({"verified_safety_number": "12345 67890 12345 67890 12345 67890"}), 200).await;
}
#[tokio::test]
async fn test_accounts_list() {
let base = setup().await;
let body = assert_get(&base, "/v1/accounts", 200).await.unwrap();
let accounts = body.as_array().unwrap();
assert!(!accounts.is_empty());
assert_eq!(accounts[0], "+1234567890");
}
#[tokio::test]
async fn test_accounts_register() {
let base = setup().await;
assert_json_request(&base, "POST", "/v1/register/+1234567890", serde_json::json!({}), 204).await;
}
#[tokio::test]
async fn test_accounts_register_with_captcha() {
let base = setup().await;
assert_json_request(&base, "POST", "/v1/register/+1234567890", serde_json::json!({"captcha": "signalcaptcha://signal-recaptcha-v2.abc123"}), 204).await;
}
#[tokio::test]
async fn test_accounts_register_voice() {
let base = setup().await;
assert_json_request(&base, "POST", "/v1/register/+1234567890", serde_json::json!({"voice": true}), 204).await;
}
#[tokio::test]
async fn test_accounts_verify() {
let base = setup().await;
assert_no_body_request(&base, "POST", "/v1/register/+1234567890/verify/123456", 204).await;
}
#[tokio::test]
async fn test_accounts_unregister() {
let base = setup().await;
assert_no_body_request(&base, "POST", "/v1/unregister/+1234567890", 204).await;
}
#[tokio::test]
async fn test_accounts_rate_limit_challenge() {
let base = setup().await;
assert_json_request(&base, "POST", "/v1/accounts/+1234567890/rate-limit-challenge", serde_json::json!({"challenge": "challenge-token", "captcha": "captcha-solution"}), 204).await;
}
#[tokio::test]
async fn test_accounts_update_settings() {
let base = setup().await;
assert_json_request(&base, "PUT", "/v1/accounts/+1234567890/settings", serde_json::json!({"trust_mode": "always"}), 204).await;
}
#[tokio::test]
async fn test_accounts_set_pin() {
let base = setup().await;
assert_json_request(&base, "POST", "/v1/accounts/+1234567890/pin", serde_json::json!({"pin": "123456"}), 204).await;
}
#[tokio::test]
async fn test_accounts_remove_pin() {
let base = setup().await;
assert_no_body_request(&base, "DELETE", "/v1/accounts/+1234567890/pin", 204).await;
}
#[tokio::test]
async fn test_accounts_set_username() {
let base = setup().await;
assert_json_request(&base, "POST", "/v1/accounts/+1234567890/username", serde_json::json!({"username": "testuser.42"}), 204).await;
}
#[tokio::test]
async fn test_accounts_remove_username() {
let base = setup().await;
assert_no_body_request(&base, "DELETE", "/v1/accounts/+1234567890/username", 204).await;
}
#[tokio::test]
async fn test_devices_list() {
let base = setup().await;
let body = assert_get(&base, "/v1/devices/+123", 200).await.unwrap();
let devices = body.as_array().unwrap();
assert!(!devices.is_empty());
assert_eq!(devices[0]["name"], "Desktop");
}
#[tokio::test]
async fn test_devices_qrcodelink() {
let base = setup().await;
let body = assert_get(&base, "/v1/qrcodelink", 200).await.unwrap();
assert!(body.get("deviceLinkUri").is_some());
}
#[tokio::test]
async fn test_devices_qrcodelink_with_name() {
let base = setup().await;
assert_get(&base, "/v1/qrcodelink?device_name=MyDesktop", 200).await;
}
#[tokio::test]
async fn test_devices_qrcodelink_raw() {
let base = setup().await;
let res = reqwest::get(format!("{base}/v1/qrcodelink/raw"))
.await
.unwrap();
assert_eq!(res.status(), 200);
let body = res.text().await.unwrap();
assert!(body.contains("sgnl://") || body.is_empty() || !body.starts_with('{'));
}
#[tokio::test]
async fn test_devices_link() {
let base = setup().await;
assert_json_request(&base, "POST", "/v1/devices/+123", serde_json::json!({"uri": "sgnl://linkdevice?uuid=test&pub_key=abc"}), 204).await;
}
#[tokio::test]
async fn test_devices_link_with_name() {
let base = setup().await;
assert_json_request(&base, "POST", "/v1/devices/+123", serde_json::json!({"uri": "sgnl://linkdevice?uuid=test&pub_key=abc", "device_name": "My Laptop"}), 204).await;
}
#[tokio::test]
async fn test_devices_remove() {
let base = setup().await;
assert_no_body_request(&base, "DELETE", "/v1/devices/+123/2", 204).await;
}
#[tokio::test]
async fn test_devices_delete_local_data() {
let base = setup().await;
assert_no_body_request(&base, "DELETE", "/v1/devices/+123/local-data", 204).await;
}
#[tokio::test]
async fn test_attachments_list() {
let base = setup().await;
let body = assert_get(&base, "/v1/attachments", 200).await.unwrap();
let attachments = body.as_array().unwrap();
assert!(!attachments.is_empty());
assert_eq!(attachments[0]["filename"], "photo.jpg");
}
#[tokio::test]
async fn test_attachments_get() {
let base = setup().await;
let body = assert_get(&base, "/v1/attachments/att1", 200).await.unwrap();
assert_eq!(body["id"], "att1");
assert_eq!(body["size"], 12345);
}
#[tokio::test]
async fn test_attachments_delete() {
let base = setup().await;
assert_no_body_request(&base, "DELETE", "/v1/attachments/att1", 204).await;
}
#[tokio::test]
async fn test_config_get_global() {
let base = setup().await;
let body = assert_get(&base, "/v1/configuration", 200).await.unwrap();
assert_eq!(body["trustMode"], "always");
}
#[tokio::test]
async fn test_config_set_global() {
let base = setup().await;
assert_json_request(&base, "POST", "/v1/configuration", serde_json::json!({"trustMode": "always"}), 204).await;
}
#[tokio::test]
async fn test_config_get_account() {
let base = setup().await;
let body = assert_get(&base, "/v1/configuration/+123/settings", 200).await.unwrap();
assert_eq!(body["trustMode"], "on-first-use");
}
#[tokio::test]
async fn test_config_set_account() {
let base = setup().await;
assert_json_request(&base, "POST", "/v1/configuration/+123/settings", serde_json::json!({"trustMode": "always"}), 204).await;
}
#[tokio::test]
async fn test_stickers_list() {
let base = setup().await;
let body = assert_get(&base, "/v1/sticker-packs/+123", 200).await.unwrap();
let packs = body.as_array().unwrap();
assert!(!packs.is_empty());
assert_eq!(packs[0]["title"], "Cool Pack");
}
#[tokio::test]
async fn test_stickers_install() {
let base = setup().await;
let body = assert_json_request(&base, "POST", "/v1/sticker-packs/+123", serde_json::json!({"packId": "abc123", "packKey": "key456"}), 201).await;
assert_eq!(body.unwrap()["packId"], "sp2");
}
#[tokio::test]
async fn test_polls_create() {
let base = setup().await;
let body = assert_json_request(&base, "POST", "/v1/polls/+123", serde_json::json!({"recipient": "+9999", "question": "Favorite language?", "options": ["Rust", "Python", "Go"]}), 201).await;
assert_eq!(body.unwrap()["timestamp"], 1234567890);
}
#[tokio::test]
async fn test_polls_vote() {
let base = setup().await;
assert_json_request(&base, "POST", "/v1/polls/+123/vote", serde_json::json!({"recipient": "+9999", "pollId": "poll1", "optionIndex": 0}), 200).await;
}
#[tokio::test]
async fn test_polls_close() {
let base = setup().await;
assert_json_request(&base, "DELETE", "/v1/polls/+123", serde_json::json!({"recipient": "+9999", "pollId": "poll1"}), 200).await;
}
#[tokio::test]
async fn test_search() {
let base = setup().await;
let body = assert_get(&base, "/v1/search/+123?numbers=+1111", 200).await.unwrap();
let results = body.as_array().unwrap();
assert!(!results.is_empty());
assert_eq!(results[0]["registered"], true);
}
#[tokio::test]
async fn test_search_multiple_numbers() {
let base = setup().await;
let body = assert_get(&base, "/v1/search/+123?numbers=+1111,+2222,+3333", 200).await.unwrap();
assert!(body.as_array().is_some());
}
#[tokio::test]
async fn test_search_empty_query() {
let base = setup().await;
assert_get(&base, "/v1/search/+123?numbers=", 200).await;
}
#[tokio::test]
async fn test_webhooks_lifecycle() {
let base = setup().await;
let client = reqwest::Client::new();
let res = client
.post(format!("{base}/v1/webhooks"))
.json(&serde_json::json!({
"url": "https://example.com/hook"
}))
.send()
.await
.unwrap();
assert_eq!(res.status(), 201);
let created: serde_json::Value = res.json().await.unwrap();
let webhook_id = created["id"].as_str().unwrap().to_string();
assert!(created.get("url").is_some());
assert_eq!(created["url"], "https://example.com/hook");
let res = client
.get(format!("{base}/v1/webhooks"))
.send()
.await
.unwrap();
assert_eq!(res.status(), 200);
let list: serde_json::Value = res.json().await.unwrap();
assert_eq!(list.as_array().unwrap().len(), 1);
let res = client
.delete(format!("{base}/v1/webhooks/{webhook_id}"))
.send()
.await
.unwrap();
assert_eq!(res.status(), 204);
let res = client
.get(format!("{base}/v1/webhooks"))
.send()
.await
.unwrap();
let list: serde_json::Value = res.json().await.unwrap();
assert_eq!(list.as_array().unwrap().len(), 0);
}
#[tokio::test]
async fn test_webhooks_with_event_filter() {
let base = setup().await;
let client = reqwest::Client::new();
let res = client
.post(format!("{base}/v1/webhooks"))
.json(&serde_json::json!({
"url": "https://example.com/hook",
"events": ["message", "receipt"]
}))
.send()
.await
.unwrap();
assert_eq!(res.status(), 201);
let body: serde_json::Value = res.json().await.unwrap();
let events = body["events"].as_array().unwrap();
assert_eq!(events.len(), 2);
}
#[tokio::test]
async fn test_webhooks_delete_nonexistent() {
let base = setup().await;
let client = reqwest::Client::new();
let res = client
.delete(format!("{base}/v1/webhooks/nonexistent-id"))
.send()
.await
.unwrap();
assert_eq!(res.status(), 404);
}
#[tokio::test]
async fn test_webhooks_multiple_create_and_list() {
let base = setup().await;
let client = reqwest::Client::new();
for i in 1..=3 {
let res = client
.post(format!("{base}/v1/webhooks"))
.json(&serde_json::json!({
"url": format!("https://example.com/hook{i}")
}))
.send()
.await
.unwrap();
assert_eq!(res.status(), 201);
tokio::time::sleep(std::time::Duration::from_millis(2)).await;
}
let res = client
.get(format!("{base}/v1/webhooks"))
.send()
.await
.unwrap();
let list: serde_json::Value = res.json().await.unwrap();
assert_eq!(list.as_array().unwrap().len(), 3);
}
#[tokio::test]
async fn test_metrics() {
let base = setup().await;
let res = reqwest::get(format!("{base}/metrics")).await.unwrap();
assert_eq!(res.status(), 200);
let body = res.text().await.unwrap();
assert!(body.contains("signal_messages_sent_total"));
assert!(body.contains("signal_messages_received_total"));
assert!(body.contains("signal_rpc_calls_total"));
assert!(body.contains("signal_rpc_errors_total"));
assert!(body.contains("signal_ws_clients_active"));
}
#[tokio::test]
async fn test_metrics_content_type() {
let base = setup().await;
let res = reqwest::get(format!("{base}/metrics")).await.unwrap();
let ct = res
.headers()
.get("content-type")
.unwrap()
.to_str()
.unwrap()
.to_string();
assert!(ct.contains("text/plain"));
}
#[tokio::test]
async fn test_metrics_prometheus_format() {
let base = setup().await;
let res = reqwest::get(format!("{base}/metrics")).await.unwrap();
let body = res.text().await.unwrap();
assert!(body.contains("# HELP signal_messages_sent_total"));
assert!(body.contains("# TYPE signal_messages_sent_total counter"));
assert!(body.contains("# TYPE signal_ws_clients_active gauge"));
}
#[tokio::test]
async fn test_metrics_increment_after_send() {
let harness = setup_full().await;
let base = &harness.base_url;
let client = reqwest::Client::new();
let res = reqwest::get(format!("{base}/metrics")).await.unwrap();
let before = res.text().await.unwrap();
client
.post(format!("{base}/v2/send"))
.json(&serde_json::json!({
"message": "test",
"number": "+123",
"recipients": ["+999"]
}))
.send()
.await
.unwrap();
let res = reqwest::get(format!("{base}/metrics")).await.unwrap();
let after = res.text().await.unwrap();
fn extract_metric(text: &str, name: &str) -> u64 {
for line in text.lines() {
if line.starts_with(name) && !line.starts_with(&format!("{name}_")) && !line.starts_with('#') {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() == 2 && parts[0] == name {
return parts[1].parse().unwrap_or(0);
}
}
}
0
}
let sent_before = extract_metric(&before, "signal_messages_sent_total");
let sent_after = extract_metric(&after, "signal_messages_sent_total");
assert!(
sent_after > sent_before,
"sent counter should increase: before={sent_before}, after={sent_after}"
);
}
#[tokio::test]
async fn test_metrics_rpc_counter() {
let harness = setup_full().await;
let base = &harness.base_url;
reqwest::get(format!("{base}/v1/accounts")).await.unwrap();
let rpc_calls = harness
.metrics
.rpc_calls
.load(std::sync::atomic::Ordering::Relaxed);
assert!(rpc_calls > 0, "RPC calls counter should be > 0");
}
#[tokio::test]
async fn test_openapi() {
let base = setup().await;
let res = reqwest::get(format!("{base}/v1/openapi.json"))
.await
.unwrap();
assert_eq!(res.status(), 200);
let body: serde_json::Value = res.json().await.unwrap();
assert_eq!(body["openapi"], "3.0.3");
assert!(body.get("info").is_some());
assert!(body.get("paths").is_some());
assert!(body.get("components").is_some());
}
#[tokio::test]
async fn test_openapi_has_required_paths() {
let base = setup().await;
let res = reqwest::get(format!("{base}/v1/openapi.json"))
.await
.unwrap();
let body: serde_json::Value = res.json().await.unwrap();
let paths = body["paths"].as_object().unwrap();
assert!(paths.contains_key("/v2/send"));
assert!(paths.contains_key("/v1/health"));
assert!(paths.contains_key("/v1/about"));
assert!(paths.contains_key("/v1/webhooks"));
assert!(paths.contains_key("/metrics"));
}
#[tokio::test]
async fn test_openapi_content_type_json() {
let base = setup().await;
let res = reqwest::get(format!("{base}/v1/openapi.json"))
.await
.unwrap();
let ct = res
.headers()
.get("content-type")
.unwrap()
.to_str()
.unwrap()
.to_string();
assert!(ct.contains("application/json"));
}
#[tokio::test]
async fn test_websocket_connect_and_receive() {
let harness = setup_full().await;
let ws_url = harness
.base_url
.replace("http://", "ws://");
let (mut ws_stream, _) =
tokio_tungstenite::connect_async(format!("{ws_url}/v1/receive/+123"))
.await
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let fake_msg = serde_json::json!({
"envelope": {
"source": "+9999",
"dataMessage": {"message": "Hello from test", "timestamp": 999}
}
});
harness
.broadcast_tx
.send(serde_json::to_string(&fake_msg).unwrap())
.unwrap();
use futures_util::StreamExt;
let msg = tokio::time::timeout(
std::time::Duration::from_secs(2),
ws_stream.next(),
)
.await
.expect("timeout waiting for WS message")
.expect("stream ended")
.expect("WS error");
let text = msg.into_text().unwrap();
let parsed: serde_json::Value = serde_json::from_str(&text).unwrap();
assert_eq!(parsed["envelope"]["source"], "+9999");
assert_eq!(
parsed["envelope"]["dataMessage"]["message"],
"Hello from test"
);
}
#[tokio::test]
async fn test_websocket_multiple_messages() {
let harness = setup_full().await;
let ws_url = harness
.base_url
.replace("http://", "ws://");
let (mut ws_stream, _) =
tokio_tungstenite::connect_async(format!("{ws_url}/v1/receive/+123"))
.await
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
for i in 0..5 {
let msg = serde_json::json!({"seq": i});
harness
.broadcast_tx
.send(serde_json::to_string(&msg).unwrap())
.unwrap();
}
use futures_util::StreamExt;
for i in 0..5 {
let msg = tokio::time::timeout(
std::time::Duration::from_secs(2),
ws_stream.next(),
)
.await
.expect("timeout")
.expect("stream ended")
.expect("WS error");
let parsed: serde_json::Value =
serde_json::from_str(&msg.into_text().unwrap()).unwrap();
assert_eq!(parsed["seq"], i);
}
}
#[tokio::test]
async fn test_websocket_client_disconnect() {
let harness = setup_full().await;
let ws_url = harness
.base_url
.replace("http://", "ws://");
let (ws_stream, _) =
tokio_tungstenite::connect_async(format!("{ws_url}/v1/receive/+123"))
.await
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
drop(ws_stream);
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let res = reqwest::get(format!("{}/v1/health", harness.base_url))
.await
.unwrap();
assert_eq!(res.status(), 204);
}
#[tokio::test]
async fn test_sse_stream() {
let harness = setup_full().await;
let base = harness.base_url.clone();
let tx = harness.broadcast_tx.clone();
let sse_handle = tokio::spawn(async move {
let mut res = reqwest::get(format!("{base}/v1/events/+123"))
.await
.unwrap();
assert_eq!(res.status(), 200);
let ct = res
.headers()
.get("content-type")
.unwrap()
.to_str()
.unwrap()
.to_string();
assert!(ct.contains("text/event-stream"));
let chunk = tokio::time::timeout(
std::time::Duration::from_secs(3),
res.chunk(),
)
.await
.expect("timeout reading SSE chunk")
.unwrap()
.expect("no chunk received");
let text = String::from_utf8_lossy(&chunk);
assert!(
text.contains("SSE test"),
"SSE chunk should contain our message: {text}"
);
});
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
let msg = serde_json::json!({"type": "message", "text": "SSE test"});
tx.send(serde_json::to_string(&msg).unwrap()).unwrap();
tokio::time::timeout(std::time::Duration::from_secs(5), sse_handle)
.await
.expect("SSE test timed out")
.unwrap();
}
#[tokio::test]
async fn test_unknown_route_returns_404() {
let base = setup().await;
let res = reqwest::get(format!("{base}/v1/nonexistent"))
.await
.unwrap();
assert_eq!(res.status(), 404);
}
#[tokio::test]
async fn test_unknown_method_on_known_route() {
let base = setup().await;
let client = reqwest::Client::new();
let res = client
.patch(format!("{base}/v1/health"))
.send()
.await
.unwrap();
assert_eq!(res.status(), 405);
}
#[tokio::test]
async fn test_concurrent_requests() {
let base = setup().await;
let client = reqwest::Client::new();
let mut handles = vec![];
for i in 0..20 {
let c = client.clone();
let b = base.clone();
handles.push(tokio::spawn(async move {
let res = match i % 4 {
0 => reqwest::get(format!("{b}/v1/health")).await.unwrap(),
1 => reqwest::get(format!("{b}/v1/about")).await.unwrap(),
2 => reqwest::get(format!("{b}/v1/accounts")).await.unwrap(),
_ => c
.post(format!("{b}/v2/send"))
.json(&serde_json::json!({
"message": format!("msg-{i}"),
"number": "+123",
"recipients": ["+999"]
}))
.send()
.await
.unwrap(),
};
assert!(
res.status().is_success(),
"Request {i} failed: {}",
res.status()
);
}));
}
for h in handles {
h.await.unwrap();
}
}
#[tokio::test]
async fn test_concurrent_sends() {
let harness = setup_full().await;
let base = &harness.base_url;
let client = reqwest::Client::new();
let mut handles = vec![];
for i in 0..10 {
let c = client.clone();
let b = base.clone();
handles.push(tokio::spawn(async move {
let res = c
.post(format!("{b}/v2/send"))
.json(&serde_json::json!({
"message": format!("concurrent-{i}"),
"number": "+123",
"recipients": ["+999"]
}))
.send()
.await
.unwrap();
assert_eq!(res.status(), 201);
}));
}
for h in handles {
h.await.unwrap();
}
let sent = harness
.metrics
.messages_sent
.load(std::sync::atomic::Ordering::Relaxed);
assert_eq!(sent, 10, "Expected 10 sent messages, got {sent}");
}
#[tokio::test]
async fn test_about_build_info() {
let base = setup().await;
let res = reqwest::get(format!("{base}/v1/about")).await.unwrap();
let body: serde_json::Value = res.json().await.unwrap();
let os = body["build"]["os"].as_str().unwrap();
assert!(
["linux", "macos", "windows"].contains(&os),
"Unexpected OS: {os}"
);
let target = body["build"]["target"].as_str().unwrap();
assert!(
["x86_64", "aarch64", "arm"].contains(&target),
"Unexpected target: {target}"
);
}
#[tokio::test]
async fn test_accounts_list_contains_phone_numbers() {
let base = setup().await;
let body = assert_get(&base, "/v1/accounts", 200).await.unwrap();
for account in body.as_array().unwrap() {
let num = account.as_str().unwrap();
assert!(num.starts_with('+'), "Account should start with +: {num}");
}
}
#[tokio::test]
async fn test_groups_list_structure() {
let base = setup().await;
let body = assert_get(&base, "/v1/groups/+123", 200).await.unwrap();
for group in body.as_array().unwrap() {
assert!(group.get("id").is_some(), "Group should have 'id'");
assert!(group.get("name").is_some(), "Group should have 'name'");
}
}
#[tokio::test]
async fn test_contacts_list_structure() {
let base = setup().await;
let body = assert_get(&base, "/v1/contacts/+123", 200).await.unwrap();
for contact in body.as_array().unwrap() {
assert!(contact.get("number").is_some(), "Contact should have 'number'");
assert!(contact.get("name").is_some(), "Contact should have 'name'");
}
}
#[tokio::test]
async fn test_devices_list_structure() {
let base = setup().await;
let body = assert_get(&base, "/v1/devices/+123", 200).await.unwrap();
for device in body.as_array().unwrap() {
assert!(device.get("id").is_some(), "Device should have 'id'");
assert!(device.get("name").is_some(), "Device should have 'name'");
}
}
#[tokio::test]
async fn test_identities_list_structure() {
let base = setup().await;
let body = assert_get(&base, "/v1/identities/+123", 200).await.unwrap();
for identity in body.as_array().unwrap() {
assert!(identity.get("number").is_some(), "Identity should have 'number'");
assert!(identity.get("status").is_some(), "Identity should have 'status'");
}
}
#[tokio::test]
async fn test_health_is_fast() {
let base = setup().await;
let start = std::time::Instant::now();
let res = reqwest::get(format!("{base}/v1/health")).await.unwrap();
let elapsed = start.elapsed();
assert_eq!(res.status(), 204);
assert!(
elapsed < std::time::Duration::from_millis(500),
"Health check too slow: {elapsed:?}"
);
}
#[tokio::test]
async fn test_webhooks_delete_twice_returns_404_second_time() {
let base = setup().await;
let client = reqwest::Client::new();
let res = client
.post(format!("{base}/v1/webhooks"))
.json(&serde_json::json!({"url": "https://example.com/test"}))
.send()
.await
.unwrap();
let created: serde_json::Value = res.json().await.unwrap();
let id = created["id"].as_str().unwrap().to_string();
let res = client
.delete(format!("{base}/v1/webhooks/{id}"))
.send()
.await
.unwrap();
assert_eq!(res.status(), 204);
let res = client
.delete(format!("{base}/v1/webhooks/{id}"))
.send()
.await
.unwrap();
assert_eq!(res.status(), 404);
}
#[tokio::test]
async fn test_webhooks_empty_list_on_fresh_server() {
let base = setup().await;
let res = reqwest::get(format!("{base}/v1/webhooks"))
.await
.unwrap();
assert_eq!(res.status(), 200);
let body: serde_json::Value = res.json().await.unwrap();
assert_eq!(body.as_array().unwrap().len(), 0);
}
#[tokio::test]
async fn test_phone_number_with_spaces_in_path() {
let base = setup().await;
assert_get(&base, "/v1/groups/+1234567890", 200).await;
}
#[tokio::test]
async fn test_long_phone_number() {
let base = setup().await;
assert_get(&base, "/v1/groups/+123456789012345", 200).await;
}
#[tokio::test]
async fn test_webhook_create_during_broadcast() {
let harness = setup_full().await;
let base = &harness.base_url;
let client = reqwest::Client::new();
client
.post(format!("{base}/v1/webhooks"))
.json(&serde_json::json!({"url": "https://example.com/hook1"}))
.send()
.await
.unwrap();
let broadcast_handle = {
let tx = harness.broadcast_tx.clone();
tokio::spawn(async move {
for i in 0..10 {
let _ = tx.send(format!("{{\"seq\": {i}}}"));
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
})
};
let create_handle = {
let c = client.clone();
let b = base.to_string();
tokio::spawn(async move {
for i in 2..=5 {
let res = c
.post(format!("{b}/v1/webhooks"))
.json(&serde_json::json!({"url": format!("https://example.com/hook{i}")}))
.send()
.await
.unwrap();
assert_eq!(res.status(), 201, "Failed to create webhook {i}");
tokio::time::sleep(std::time::Duration::from_millis(3)).await;
}
})
};
let timeout = std::time::Duration::from_secs(5);
tokio::time::timeout(timeout, broadcast_handle)
.await
.expect("broadcast timed out — possible deadlock")
.unwrap();
tokio::time::timeout(timeout, create_handle)
.await
.expect("webhook creation timed out — possible deadlock")
.unwrap();
let res = client
.get(format!("{base}/v1/webhooks"))
.send()
.await
.unwrap();
let list: serde_json::Value = res.json().await.unwrap();
assert_eq!(list.as_array().unwrap().len(), 5);
}
#[tokio::test]
async fn test_concurrent_rpc_no_id_collision() {
let base = setup().await;
let client = reqwest::Client::new();
let mut handles = vec![];
for i in 0..50 {
let c = client.clone();
let b = base.clone();
handles.push(tokio::spawn(async move {
let res = c
.post(format!("{b}/v2/send"))
.json(&serde_json::json!({
"message": format!("id-test-{i}"),
"number": "+123",
"recipients": ["+999"]
}))
.send()
.await
.unwrap();
assert_eq!(res.status(), 201, "Request {i} failed");
let body: serde_json::Value = res.json().await.unwrap();
assert!(body.get("timestamp").is_some(), "Request {i} missing timestamp");
}));
}
for h in handles {
h.await.unwrap();
}
}
#[tokio::test]
async fn test_rapid_fire_messages() {
let harness = setup_full().await;
let base = &harness.base_url;
let client = reqwest::Client::new();
for i in 0..100 {
let res = client
.post(format!("{base}/v2/send"))
.json(&serde_json::json!({
"message": format!("rapid-{i}"),
"number": "+123",
"recipients": ["+999"]
}))
.send()
.await
.unwrap();
assert_eq!(res.status(), 201, "Failed at message {i}");
}
let sent = harness.metrics.messages_sent.load(std::sync::atomic::Ordering::Relaxed);
assert_eq!(sent, 100);
}
async fn setup_tls() -> (String, reqwest::Client) {
let _ = rustls::crypto::ring::default_provider().install_default();
let mock_addr = start_mock_signal_cli().await;
let stream = tokio::net::TcpStream::connect(mock_addr).await.unwrap();
let (reader, writer) = stream.into_split();
let (writer_tx, writer_rx) = tokio::sync::mpsc::channel::<String>(256);
tokio::spawn(signal_cli_api::jsonrpc::writer_loop(writer_rx, writer));
let state = signal_cli_api::state::AppState::new(writer_tx);
let broadcast_tx = state.broadcast_tx.clone();
let pending = state.pending.clone();
let metrics = state.metrics.clone();
tokio::spawn(signal_cli_api::jsonrpc::reader_loop(
reader,
broadcast_tx,
pending,
metrics,
));
let app = signal_cli_api::routes::router(state);
let cert = rcgen::generate_simple_self_signed(vec!["localhost".to_string()]).unwrap();
let cert_pem = cert.cert.pem();
let key_pem = cert.key_pair.serialize_pem();
let tls_config = axum_server::tls_rustls::RustlsConfig::from_pem(
cert_pem.as_bytes().to_vec(),
key_pem.as_bytes().to_vec(),
)
.await
.unwrap();
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum_server::from_tcp_rustls(listener, tls_config)
.serve(app.into_make_service())
.await
.unwrap();
});
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let cert_for_client = reqwest::tls::Certificate::from_pem(cert_pem.as_bytes()).unwrap();
let client = reqwest::Client::builder()
.add_root_certificate(cert_for_client)
.build()
.unwrap();
(format!("https://localhost:{}", addr.port()), client)
}
#[tokio::test]
async fn test_tls_health() {
let (base, client) = setup_tls().await;
let res = client.get(format!("{base}/v1/health")).send().await.unwrap();
assert_eq!(res.status(), 204);
}
#[tokio::test]
async fn test_tls_send_message() {
let (base, client) = setup_tls().await;
let res = client
.post(format!("{base}/v2/send"))
.json(&serde_json::json!({
"message": "TLS test",
"number": "+123",
"recipients": ["+999"]
}))
.send()
.await
.unwrap();
assert_eq!(res.status(), 201);
let body: serde_json::Value = res.json().await.unwrap();
assert_eq!(body["timestamp"], 1234567890);
}
#[tokio::test]
async fn test_tls_about() {
let (base, client) = setup_tls().await;
let res = client.get(format!("{base}/v1/about")).send().await.unwrap();
assert_eq!(res.status(), 200);
let body: serde_json::Value = res.json().await.unwrap();
assert!(body.get("versions").is_some());
}
#[tokio::test]
async fn test_send_v2_rpc_error() {
let base = setup().await;
let body = assert_json_request(&base, "POST", "/v2/send", serde_json::json!({"message": "will fail", "number": "+ERROR", "recipients": ["+999"]}), 400).await;
assert!(body.unwrap().get("error").is_some());
}
#[tokio::test]
async fn test_send_v1_rpc_error() {
let base = setup().await;
let body = assert_json_request(&base, "POST", "/v1/send", serde_json::json!({"message": "will fail", "number": "+ERROR", "recipients": ["+999"]}), 400).await;
assert!(body.unwrap().get("error").is_some());
}
#[tokio::test]
async fn test_groups_list_rpc_error() {
let base = setup().await;
let body = assert_get(&base, "/v1/groups/+ERROR", 400).await;
assert!(body.unwrap().get("error").is_some());
}
#[tokio::test]
async fn test_groups_create_rpc_error() {
let base = setup().await;
assert_json_request(&base, "POST", "/v1/groups/+ERROR", serde_json::json!({"name": "Fail Group", "members": ["+999"]}), 400).await;
}
#[tokio::test]
async fn test_groups_update_rpc_error() {
let base = setup().await;
assert_json_request(&base, "PUT", "/v1/groups/+ERROR/g1", serde_json::json!({"name": "Fail"}), 400).await;
}
#[tokio::test]
async fn test_groups_delete_rpc_error() {
let base = setup().await;
assert_no_body_request(&base, "DELETE", "/v1/groups/+ERROR/g1", 400).await;
}
#[tokio::test]
async fn test_contacts_list_rpc_error() {
let base = setup().await;
assert_get(&base, "/v1/contacts/+ERROR", 400).await;
}
#[tokio::test]
async fn test_identities_list_rpc_error() {
let base = setup().await;
assert_get(&base, "/v1/identities/+ERROR", 400).await;
}
#[tokio::test]
async fn test_devices_list_rpc_error() {
let base = setup().await;
assert_get(&base, "/v1/devices/+ERROR", 400).await;
}
#[tokio::test]
async fn test_typing_rpc_error() {
let base = setup().await;
assert_json_request(&base, "PUT", "/v1/typing-indicator/+ERROR", serde_json::json!({"recipient": "+999"}), 400).await;
}
#[tokio::test]
async fn test_reaction_rpc_error() {
let base = setup().await;
assert_json_request(&base, "POST", "/v1/reactions/+ERROR", serde_json::json!({"recipient": "+999", "reaction": "👍", "target_author": "+999", "timestamp": 12345}), 400).await;
}
#[tokio::test]
async fn test_receipt_rpc_error() {
let base = setup().await;
assert_json_request(&base, "POST", "/v1/receipts/+ERROR", serde_json::json!({"receipt_type": "read", "recipient": "+999", "timestamp": 12345}), 400).await;
}
#[tokio::test]
async fn test_search_rpc_error() {
let base = setup().await;
assert_get(&base, "/v1/search/+ERROR?numbers=+111", 400).await;
}
#[tokio::test]
async fn test_polls_create_rpc_error() {
let base = setup().await;
assert_json_request(&base, "POST", "/v1/polls/+ERROR", serde_json::json!({"recipient": "+999", "question": "?", "options": ["A", "B"]}), 400).await;
}
#[tokio::test]
async fn test_stickers_list_rpc_error() {
let base = setup().await;
assert_get(&base, "/v1/sticker-packs/+ERROR", 400).await;
}
#[tokio::test]
async fn test_config_get_account_rpc_error() {
let base = setup().await;
assert_get(&base, "/v1/configuration/+ERROR/settings", 400).await;
}
#[tokio::test]
async fn test_profiles_update_rpc_error() {
let base = setup().await;
assert_json_request(&base, "PUT", "/v1/profiles/+ERROR", serde_json::json!({"name": "Fail"}), 400).await;
}
#[tokio::test]
async fn test_remote_delete_rpc_error() {
let base = setup().await;
assert_json_request(&base, "DELETE", "/v1/remote-delete/+ERROR", serde_json::json!({"recipient": "+999", "timestamp": 12345}), 400).await;
}
#[tokio::test]
async fn test_metrics_rpc_error_counter() {
let harness = setup_full().await;
let base = &harness.base_url;
let client = reqwest::Client::new();
let _ = client
.post(format!("{base}/v2/send"))
.json(&serde_json::json!({
"message": "fail",
"number": "+ERROR",
"recipients": ["+999"]
}))
.send()
.await
.unwrap();
let rpc_errors = harness
.metrics
.rpc_errors
.load(std::sync::atomic::Ordering::Relaxed);
assert!(rpc_errors > 0, "RPC errors counter should be > 0 after error, got {rpc_errors}");
}
#[tokio::test]
async fn test_metrics_zero_on_startup() {
let harness = setup_full().await;
let sent = harness.metrics.messages_sent.load(std::sync::atomic::Ordering::Relaxed);
let received = harness.metrics.messages_received.load(std::sync::atomic::Ordering::Relaxed);
assert_eq!(sent, 0, "messages_sent should start at 0");
assert_eq!(received, 0, "messages_received should start at 0");
}
#[tokio::test]
async fn test_metrics_received_counter_after_broadcast() {
let harness = setup_full().await;
let ws_clients = harness.metrics.ws_clients.load(std::sync::atomic::Ordering::Relaxed);
assert_eq!(ws_clients, 0, "ws_clients should be 0 with no WS connections");
}
#[tokio::test]
async fn test_metrics_sent_not_incremented_on_v1_send() {
let harness = setup_full().await;
let base = &harness.base_url;
let client = reqwest::Client::new();
client
.post(format!("{base}/v1/send"))
.json(&serde_json::json!({
"message": "v1",
"number": "+123",
"recipients": ["+999"]
}))
.send()
.await
.unwrap();
let sent = harness.metrics.messages_sent.load(std::sync::atomic::Ordering::Relaxed);
assert_eq!(sent, 0, "v1/send should NOT increment sent counter");
}
#[tokio::test]
async fn test_metrics_error_not_counted_as_sent() {
let harness = setup_full().await;
let base = &harness.base_url;
let client = reqwest::Client::new();
client
.post(format!("{base}/v2/send"))
.json(&serde_json::json!({
"message": "fail",
"number": "+ERROR",
"recipients": ["+999"]
}))
.send()
.await
.unwrap();
let sent = harness.metrics.messages_sent.load(std::sync::atomic::Ordering::Relaxed);
assert_eq!(sent, 0, "Failed send should NOT increment sent counter");
}
#[tokio::test]
async fn test_websocket_two_clients_receive_same_message() {
let harness = setup_full().await;
let ws_url = harness.base_url.replace("http://", "ws://");
let (mut ws1, _) =
tokio_tungstenite::connect_async(format!("{ws_url}/v1/receive/+123"))
.await
.unwrap();
let (mut ws2, _) =
tokio_tungstenite::connect_async(format!("{ws_url}/v1/receive/+123"))
.await
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let msg = serde_json::json!({"text": "both clients"});
harness.broadcast_tx.send(serde_json::to_string(&msg).unwrap()).unwrap();
use futures_util::StreamExt;
for ws in [&mut ws1, &mut ws2] {
let received = tokio::time::timeout(
std::time::Duration::from_secs(2),
ws.next(),
)
.await
.expect("timeout")
.expect("stream ended")
.expect("WS error");
let parsed: serde_json::Value =
serde_json::from_str(&received.into_text().unwrap()).unwrap();
assert_eq!(parsed["text"], "both clients");
}
}
#[tokio::test]
async fn test_ws_client_counter_increments() {
let harness = setup_full().await;
let ws_url = harness.base_url.replace("http://", "ws://");
assert_eq!(
harness.metrics.ws_clients.load(std::sync::atomic::Ordering::Relaxed),
0,
"Should start with 0 WS clients"
);
let (_ws1, _) =
tokio_tungstenite::connect_async(format!("{ws_url}/v1/receive/+123"))
.await
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
assert_eq!(
harness.metrics.ws_clients.load(std::sync::atomic::Ordering::Relaxed),
1,
"Should have 1 WS client after connect"
);
let (_ws2, _) =
tokio_tungstenite::connect_async(format!("{ws_url}/v1/receive/+456"))
.await
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
assert_eq!(
harness.metrics.ws_clients.load(std::sync::atomic::Ordering::Relaxed),
2,
"Should have 2 WS clients"
);
}
#[tokio::test]
async fn test_ws_client_counter_decrements_on_disconnect() {
let harness = setup_full().await;
let ws_url = harness.base_url.replace("http://", "ws://");
let (ws1, _) =
tokio_tungstenite::connect_async(format!("{ws_url}/v1/receive/+123"))
.await
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
assert_eq!(
harness.metrics.ws_clients.load(std::sync::atomic::Ordering::Relaxed),
1
);
drop(ws1);
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
assert_eq!(
harness.metrics.ws_clients.load(std::sync::atomic::Ordering::Relaxed),
0,
"WS client counter should return to 0 after disconnect"
);
}
#[tokio::test]
async fn test_websocket_large_message() {
let harness = setup_full().await;
let ws_url = harness.base_url.replace("http://", "ws://");
let (mut ws_stream, _) =
tokio_tungstenite::connect_async(format!("{ws_url}/v1/receive/+123"))
.await
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let large_text = "x".repeat(100_000);
let msg = serde_json::json!({"data": large_text});
harness.broadcast_tx.send(serde_json::to_string(&msg).unwrap()).unwrap();
use futures_util::StreamExt;
let received = tokio::time::timeout(
std::time::Duration::from_secs(5),
ws_stream.next(),
)
.await
.expect("timeout")
.expect("stream ended")
.expect("WS error");
let parsed: serde_json::Value =
serde_json::from_str(&received.into_text().unwrap()).unwrap();
assert_eq!(parsed["data"].as_str().unwrap().len(), 100_000);
}
#[tokio::test]
async fn test_websocket_unicode_broadcast() {
let harness = setup_full().await;
let ws_url = harness.base_url.replace("http://", "ws://");
let (mut ws_stream, _) =
tokio_tungstenite::connect_async(format!("{ws_url}/v1/receive/+123"))
.await
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let msg = serde_json::json!({"text": "Hello 🌍🔥 Привет 日本語"});
harness.broadcast_tx.send(serde_json::to_string(&msg).unwrap()).unwrap();
use futures_util::StreamExt;
let received = tokio::time::timeout(
std::time::Duration::from_secs(2),
ws_stream.next(),
)
.await
.expect("timeout")
.expect("stream ended")
.expect("WS error");
let parsed: serde_json::Value =
serde_json::from_str(&received.into_text().unwrap()).unwrap();
assert_eq!(parsed["text"], "Hello 🌍🔥 Привет 日本語");
}
#[tokio::test]
async fn test_websocket_rapid_broadcast() {
let harness = setup_full().await;
let ws_url = harness.base_url.replace("http://", "ws://");
let (mut ws_stream, _) =
tokio_tungstenite::connect_async(format!("{ws_url}/v1/receive/+123"))
.await
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
for i in 0..50 {
let msg = serde_json::json!({"seq": i});
harness.broadcast_tx.send(serde_json::to_string(&msg).unwrap()).unwrap();
}
use futures_util::StreamExt;
for i in 0..50 {
let received = tokio::time::timeout(
std::time::Duration::from_secs(5),
ws_stream.next(),
)
.await
.expect(&format!("timeout at message {i}"))
.expect("stream ended")
.expect("WS error");
let parsed: serde_json::Value =
serde_json::from_str(&received.into_text().unwrap()).unwrap();
assert_eq!(parsed["seq"], i, "Message ordering mismatch at {i}");
}
}
#[tokio::test]
async fn test_sse_event_format() {
let harness = setup_full().await;
let base = harness.base_url.clone();
let tx = harness.broadcast_tx.clone();
let sse_handle = tokio::spawn(async move {
let mut res = reqwest::get(format!("{base}/v1/events/+123"))
.await
.unwrap();
let chunk = tokio::time::timeout(
std::time::Duration::from_secs(3),
res.chunk(),
)
.await
.expect("timeout")
.unwrap()
.expect("no chunk");
let text = String::from_utf8_lossy(&chunk);
assert!(text.contains("event:"), "SSE should contain event field: {text}");
assert!(text.contains("data:"), "SSE should contain data field: {text}");
});
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
let msg = serde_json::json!({"format": "test"});
tx.send(serde_json::to_string(&msg).unwrap()).unwrap();
tokio::time::timeout(std::time::Duration::from_secs(5), sse_handle)
.await
.expect("SSE test timed out")
.unwrap();
}
#[tokio::test]
async fn test_sse_multiple_events() {
let harness = setup_full().await;
let base = harness.base_url.clone();
let tx = harness.broadcast_tx.clone();
let sse_handle = tokio::spawn(async move {
let mut res = reqwest::get(format!("{base}/v1/events/+123"))
.await
.unwrap();
for i in 0..2 {
let chunk = tokio::time::timeout(
std::time::Duration::from_secs(3),
res.chunk(),
)
.await
.expect(&format!("timeout on event {i}"))
.unwrap()
.expect(&format!("no chunk for event {i}"));
let text = String::from_utf8_lossy(&chunk);
assert!(
text.contains(&format!("seq{i}")),
"Event {i} should contain seq{i}: {text}"
);
}
});
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
for i in 0..2 {
let msg = serde_json::json!({"marker": format!("seq{i}")});
tx.send(serde_json::to_string(&msg).unwrap()).unwrap();
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
tokio::time::timeout(std::time::Duration::from_secs(5), sse_handle)
.await
.expect("SSE multi-event test timed out")
.unwrap();
}
#[tokio::test]
async fn test_about_content_type_json() {
let base = setup().await;
let res = reqwest::get(format!("{base}/v1/about")).await.unwrap();
let ct = res.headers().get("content-type").unwrap().to_str().unwrap();
assert!(ct.contains("application/json"), "About should return JSON, got: {ct}");
}
#[tokio::test]
async fn test_health_has_no_body() {
let base = setup().await;
let res = reqwest::get(format!("{base}/v1/health")).await.unwrap();
assert_eq!(res.status(), 204);
let body = res.text().await.unwrap();
assert!(body.is_empty(), "204 health should have no body, got: {body}");
}
#[tokio::test]
async fn test_send_response_content_type() {
let base = setup().await;
let client = reqwest::Client::new();
let res = client
.post(format!("{base}/v2/send"))
.json(&serde_json::json!({
"message": "ct test",
"number": "+123",
"recipients": ["+999"]
}))
.send()
.await
.unwrap();
let ct = res.headers().get("content-type").unwrap().to_str().unwrap();
assert!(ct.contains("application/json"), "Send response should be JSON, got: {ct}");
}
#[tokio::test]
async fn test_groups_response_content_type() {
let base = setup().await;
let res = reqwest::get(format!("{base}/v1/groups/+123")).await.unwrap();
let ct = res.headers().get("content-type").unwrap().to_str().unwrap();
assert!(ct.contains("application/json"), "Groups response should be JSON, got: {ct}");
}
#[tokio::test]
async fn test_cors_headers_present() {
let base = setup().await;
let client = reqwest::Client::new();
let res = client
.get(format!("{base}/v1/health"))
.header("Origin", "https://example.com")
.send()
.await
.unwrap();
let acah = res.headers().get("access-control-allow-origin");
assert!(acah.is_some(), "CORS header should be present");
assert_eq!(acah.unwrap().to_str().unwrap(), "*");
}
#[tokio::test]
async fn test_cors_preflight_options() {
let base = setup().await;
let client = reqwest::Client::new();
let res = client
.request(reqwest::Method::OPTIONS, format!("{base}/v2/send"))
.header("Origin", "https://example.com")
.header("Access-Control-Request-Method", "POST")
.send()
.await
.unwrap();
assert!(res.status().is_success(), "CORS preflight should succeed");
let acam = res.headers().get("access-control-allow-methods");
assert!(acam.is_some(), "CORS should return allowed methods");
}
#[tokio::test]
async fn test_send_v2_to_group() {
let base = setup().await;
assert_json_request(&base, "POST", "/v2/send", serde_json::json!({"message": "group hello", "number": "+1234567890", "recipients": [], "group-id": "g1"}), 201).await;
}
#[tokio::test]
async fn test_send_v2_with_quote() {
let base = setup().await;
assert_json_request(&base, "POST", "/v2/send", serde_json::json!({"message": "replying to you", "number": "+1234567890", "recipients": ["+9999"], "quote_timestamp": 1234567890, "quote_author": "+9999", "quote_message": "original message"}), 201).await;
}
#[tokio::test]
async fn test_send_v2_with_mentions() {
let base = setup().await;
assert_json_request(&base, "POST", "/v2/send", serde_json::json!({"message": "Hey @user check this", "number": "+1234567890", "recipients": ["+9999"], "mentions": [{"start": 4, "length": 5, "uuid": "abc-123"}]}), 201).await;
}
#[tokio::test]
async fn test_send_v2_very_long_message() {
let base = setup().await;
let long_msg = "A".repeat(10_000);
assert_json_request(&base, "POST", "/v2/send", serde_json::json!({"message": long_msg, "number": "+1234567890", "recipients": ["+9999"]}), 201).await;
}
#[tokio::test]
async fn test_send_v2_newlines_in_message() {
let base = setup().await;
assert_json_request(&base, "POST", "/v2/send", serde_json::json!({"message": "line1\nline2\nline3\n\n\nline6", "number": "+1234567890", "recipients": ["+9999"]}), 201).await;
}
#[tokio::test]
async fn test_send_v2_json_in_message() {
let base = setup().await;
assert_json_request(&base, "POST", "/v2/send", serde_json::json!({"message": "{\"key\": \"value\", \"nested\": {\"a\": 1}}", "number": "+1234567890", "recipients": ["+9999"]}), 201).await;
}
#[tokio::test]
async fn test_send_v2_special_chars() {
let base = setup().await;
assert_json_request(&base, "POST", "/v2/send", serde_json::json!({"message": "Special: <script>alert('xss')</script> & \"quotes\" 'single' `backtick`", "number": "+1234567890", "recipients": ["+9999"]}), 201).await;
}
#[tokio::test]
async fn test_groups_update_all_fields() {
let base = setup().await;
assert_json_request(&base, "PUT", "/v1/groups/+123/g1", serde_json::json!({"name": "Full Update", "description": "Updated description", "base64_avatar": "aGVsbG8=", "expiration": 604800, "permissions": {"add_members": "only-admins", "edit_details": "only-admins"}}), 200).await;
}
#[tokio::test]
async fn test_groups_create_many_members() {
let base = setup().await;
let members: Vec<String> = (0..20).map(|i| format!("+{:010}", i)).collect();
assert_json_request(&base, "POST", "/v1/groups/+123", serde_json::json!({"name": "Big Group", "members": members}), 201).await;
}
#[tokio::test]
async fn test_groups_lifecycle() {
let base = setup().await;
let client = reqwest::Client::new();
let res = client
.post(format!("{base}/v1/groups/+123"))
.json(&serde_json::json!({"name": "Lifecycle", "members": ["+999"]}))
.send()
.await
.unwrap();
assert_eq!(res.status(), 201);
let res = client
.put(format!("{base}/v1/groups/+123/g1"))
.json(&serde_json::json!({"name": "Lifecycle v2"}))
.send()
.await
.unwrap();
assert_eq!(res.status(), 200);
let res = client
.post(format!("{base}/v1/groups/+123/g1/members"))
.json(&serde_json::json!({"members": ["+888"]}))
.send()
.await
.unwrap();
assert_eq!(res.status(), 200);
let res = reqwest::get(format!("{base}/v1/groups/+123/g1")).await.unwrap();
assert_eq!(res.status(), 200);
let res = client
.delete(format!("{base}/v1/groups/+123/g1"))
.send()
.await
.unwrap();
assert_eq!(res.status(), 200);
}
#[tokio::test]
async fn test_groups_add_and_remove_members() {
let base = setup().await;
let client = reqwest::Client::new();
let res = client
.post(format!("{base}/v1/groups/+123/g1/members"))
.json(&serde_json::json!({"members": ["+111", "+222", "+333"]}))
.send()
.await
.unwrap();
assert_eq!(res.status(), 200);
let res = client
.delete(format!("{base}/v1/groups/+123/g1/members"))
.json(&serde_json::json!({"members": ["+111", "+222"]}))
.send()
.await
.unwrap();
assert_eq!(res.status(), 200);
}
#[tokio::test]
async fn test_groups_join_then_quit() {
let base = setup().await;
let client = reqwest::Client::new();
let res = client
.post(format!("{base}/v1/groups/+123/g1/join"))
.send()
.await
.unwrap();
assert_eq!(res.status(), 200);
let res = client
.post(format!("{base}/v1/groups/+123/g1/quit"))
.send()
.await
.unwrap();
assert_eq!(res.status(), 200);
}
#[tokio::test]
async fn test_profiles_update_all_fields() {
let base = setup().await;
assert_json_request(&base, "PUT", "/v1/profiles/+123", serde_json::json!({"name": "Full Profile", "about": "Security enthusiast", "base64_avatar": "aGVsbG8="}), 200).await;
}
#[tokio::test]
async fn test_contacts_update_name_only() {
let base = setup().await;
assert_json_request(&base, "PUT", "/v1/contacts/+123", serde_json::json!({"name": "Just Name"}), 200).await;
}
#[tokio::test]
async fn test_contacts_update_expiration_only() {
let base = setup().await;
assert_json_request(&base, "PUT", "/v1/contacts/+123", serde_json::json!({"expiration": 7200}), 200).await;
}
#[tokio::test]
async fn test_account_register_then_verify() {
let base = setup().await;
let client = reqwest::Client::new();
let res = client
.post(format!("{base}/v1/register/+5551234567"))
.json(&serde_json::json!({}))
.send()
.await
.unwrap();
assert_eq!(res.status(), 204);
let res = client
.post(format!("{base}/v1/register/+5551234567/verify/999999"))
.send()
.await
.unwrap();
assert_eq!(res.status(), 204);
}
#[tokio::test]
async fn test_poll_lifecycle() {
let base = setup().await;
let client = reqwest::Client::new();
let res = client
.post(format!("{base}/v1/polls/+123"))
.json(&serde_json::json!({
"recipient": "+999",
"question": "Best language?",
"options": ["Rust", "Python", "Go", "TypeScript"]
}))
.send()
.await
.unwrap();
assert_eq!(res.status(), 201);
let res = client
.post(format!("{base}/v1/polls/+123/vote"))
.json(&serde_json::json!({
"recipient": "+999",
"pollId": "poll1",
"optionIndex": 0
}))
.send()
.await
.unwrap();
assert_eq!(res.status(), 200);
let res = client
.delete(format!("{base}/v1/polls/+123"))
.json(&serde_json::json!({
"recipient": "+999",
"pollId": "poll1"
}))
.send()
.await
.unwrap();
assert_eq!(res.status(), 200);
}
#[tokio::test]
async fn test_device_qrcodelink_then_link() {
let base = setup().await;
let client = reqwest::Client::new();
let res = reqwest::get(format!("{base}/v1/qrcodelink")).await.unwrap();
assert_eq!(res.status(), 200);
let body: serde_json::Value = res.json().await.unwrap();
let uri = body["deviceLinkUri"].as_str().unwrap();
let res = client
.post(format!("{base}/v1/devices/+123"))
.json(&serde_json::json!({"uri": uri, "device_name": "Test Device"}))
.send()
.await
.unwrap();
assert_eq!(res.status(), 204);
}
#[tokio::test]
async fn test_send_and_verify_exact_metrics() {
let harness = setup_full().await;
let base = &harness.base_url;
let client = reqwest::Client::new();
for _ in 0..5 {
client
.post(format!("{base}/v2/send"))
.json(&serde_json::json!({
"message": "metric test",
"number": "+123",
"recipients": ["+999"]
}))
.send()
.await
.unwrap();
}
for _ in 0..3 {
client
.post(format!("{base}/v1/send"))
.json(&serde_json::json!({
"message": "v1 msg",
"number": "+123",
"recipients": ["+999"]
}))
.send()
.await
.unwrap();
}
let sent = harness.metrics.messages_sent.load(std::sync::atomic::Ordering::Relaxed);
assert_eq!(sent, 5, "Only v2/send should increment sent counter, expected 5 got {sent}");
let rpc = harness.metrics.rpc_calls.load(std::sync::atomic::Ordering::Relaxed);
assert!(rpc >= 8, "Expected at least 8 RPC calls, got {rpc}");
}
#[tokio::test]
async fn test_tls_groups_list() {
let (base, client) = setup_tls().await;
let res = client.get(format!("{base}/v1/groups/+123")).send().await.unwrap();
assert_eq!(res.status(), 200);
let body: serde_json::Value = res.json().await.unwrap();
assert!(body.as_array().is_some());
}
#[tokio::test]
async fn test_tls_contacts_list() {
let (base, client) = setup_tls().await;
let res = client.get(format!("{base}/v1/contacts/+123")).send().await.unwrap();
assert_eq!(res.status(), 200);
}
#[tokio::test]
async fn test_tls_metrics() {
let (base, client) = setup_tls().await;
let res = client.get(format!("{base}/metrics")).send().await.unwrap();
assert_eq!(res.status(), 200);
let body = res.text().await.unwrap();
assert!(body.contains("signal_messages_sent_total"));
}
#[tokio::test]
async fn test_tls_openapi() {
let (base, client) = setup_tls().await;
let res = client.get(format!("{base}/v1/openapi.json")).send().await.unwrap();
assert_eq!(res.status(), 200);
let body: serde_json::Value = res.json().await.unwrap();
assert_eq!(body["openapi"], "3.0.3");
}
#[tokio::test]
async fn test_tls_webhooks_lifecycle() {
let (base, client) = setup_tls().await;
let res = client
.post(format!("{base}/v1/webhooks"))
.json(&serde_json::json!({"url": "https://example.com/tls-hook"}))
.send()
.await
.unwrap();
assert_eq!(res.status(), 201);
let created: serde_json::Value = res.json().await.unwrap();
let id = created["id"].as_str().unwrap().to_string();
let res = client.get(format!("{base}/v1/webhooks")).send().await.unwrap();
let list: serde_json::Value = res.json().await.unwrap();
assert_eq!(list.as_array().unwrap().len(), 1);
let res = client.delete(format!("{base}/v1/webhooks/{id}")).send().await.unwrap();
assert_eq!(res.status(), 204);
}
#[tokio::test]
async fn test_tls_accounts_list() {
let (base, client) = setup_tls().await;
let res = client.get(format!("{base}/v1/accounts")).send().await.unwrap();
assert_eq!(res.status(), 200);
}
#[tokio::test]
async fn test_tls_devices_list() {
let (base, client) = setup_tls().await;
let res = client.get(format!("{base}/v1/devices/+123")).send().await.unwrap();
assert_eq!(res.status(), 200);
}
#[tokio::test]
async fn test_tls_concurrent_requests() {
let (base, client) = setup_tls().await;
let mut handles = vec![];
for i in 0..10 {
let c = client.clone();
let b = base.clone();
handles.push(tokio::spawn(async move {
let res = match i % 3 {
0 => c.get(format!("{b}/v1/health")).send().await.unwrap(),
1 => c.get(format!("{b}/v1/about")).send().await.unwrap(),
_ => c
.post(format!("{b}/v2/send"))
.json(&serde_json::json!({
"message": format!("tls-{i}"),
"number": "+123",
"recipients": ["+999"]
}))
.send()
.await
.unwrap(),
};
assert!(res.status().is_success(), "TLS request {i} failed: {}", res.status());
}));
}
for h in handles {
h.await.unwrap();
}
}
#[tokio::test]
async fn test_tls_identities() {
let (base, client) = setup_tls().await;
let res = client.get(format!("{base}/v1/identities/+123")).send().await.unwrap();
assert_eq!(res.status(), 200);
let body: serde_json::Value = res.json().await.unwrap();
assert!(body.as_array().is_some());
}
#[tokio::test]
async fn test_tls_stickers() {
let (base, client) = setup_tls().await;
let res = client.get(format!("{base}/v1/sticker-packs/+123")).send().await.unwrap();
assert_eq!(res.status(), 200);
}
#[tokio::test]
async fn test_concurrent_group_operations() {
let base = setup().await;
let client = reqwest::Client::new();
let mut handles = vec![];
for i in 0..10 {
let c = client.clone();
let b = base.clone();
handles.push(tokio::spawn(async move {
let res = match i % 3 {
0 => reqwest::get(format!("{b}/v1/groups/+123")).await.unwrap(),
1 => c
.post(format!("{b}/v1/groups/+123"))
.json(&serde_json::json!({"name": format!("g-{i}"), "members": ["+999"]}))
.send()
.await
.unwrap(),
_ => c
.put(format!("{b}/v1/groups/+123/g1"))
.json(&serde_json::json!({"name": format!("rename-{i}")}))
.send()
.await
.unwrap(),
};
assert!(res.status().is_success(), "Group op {i} failed");
}));
}
for h in handles {
h.await.unwrap();
}
}
#[tokio::test]
async fn test_concurrent_webhook_create_delete() {
let base = setup().await;
let client = reqwest::Client::new();
let mut ids = vec![];
for i in 0..10 {
let res = client
.post(format!("{base}/v1/webhooks"))
.json(&serde_json::json!({"url": format!("https://example.com/h{i}")}))
.send()
.await
.unwrap();
let body: serde_json::Value = res.json().await.unwrap();
ids.push(body["id"].as_str().unwrap().to_string());
tokio::time::sleep(std::time::Duration::from_millis(2)).await;
}
let mut handles = vec![];
for id in ids {
let c = client.clone();
let b = base.clone();
handles.push(tokio::spawn(async move {
let res = c.delete(format!("{b}/v1/webhooks/{id}")).send().await.unwrap();
assert_eq!(res.status(), 204, "Failed to delete webhook {id}");
}));
}
for h in handles {
h.await.unwrap();
}
let res = client.get(format!("{base}/v1/webhooks")).send().await.unwrap();
let list: serde_json::Value = res.json().await.unwrap();
assert_eq!(list.as_array().unwrap().len(), 0);
}
#[tokio::test]
async fn test_concurrent_mixed_endpoints() {
let base = setup().await;
let client = reqwest::Client::new();
let mut handles = vec![];
for i in 0..30 {
let c = client.clone();
let b = base.clone();
handles.push(tokio::spawn(async move {
let res = match i % 6 {
0 => reqwest::get(format!("{b}/v1/health")).await.unwrap(),
1 => reqwest::get(format!("{b}/v1/accounts")).await.unwrap(),
2 => reqwest::get(format!("{b}/v1/groups/+123")).await.unwrap(),
3 => reqwest::get(format!("{b}/v1/contacts/+123")).await.unwrap(),
4 => reqwest::get(format!("{b}/v1/identities/+123")).await.unwrap(),
_ => c
.post(format!("{b}/v2/send"))
.json(&serde_json::json!({
"message": format!("mix-{i}"),
"number": "+123",
"recipients": ["+999"]
}))
.send()
.await
.unwrap(),
};
assert!(res.status().is_success(), "Mixed endpoint {i} failed: {}", res.status());
}));
}
for h in handles {
h.await.unwrap();
}
}
#[tokio::test]
async fn test_rapid_fire_health_checks() {
let base = setup().await;
for _ in 0..100 {
let res = reqwest::get(format!("{base}/v1/health")).await.unwrap();
assert_eq!(res.status(), 204);
}
}
#[tokio::test]
async fn test_concurrent_ws_and_rest() {
let harness = setup_full().await;
let base = &harness.base_url;
let ws_url = base.replace("http://", "ws://");
let client = reqwest::Client::new();
let (mut ws_stream, _) =
tokio_tungstenite::connect_async(format!("{ws_url}/v1/receive/+123"))
.await
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let rest_handle = {
let c = client.clone();
let b = base.to_string();
tokio::spawn(async move {
for i in 0..10 {
let res = c
.post(format!("{b}/v2/send"))
.json(&serde_json::json!({
"message": format!("ws-rest-{i}"),
"number": "+123",
"recipients": ["+999"]
}))
.send()
.await
.unwrap();
assert_eq!(res.status(), 201);
}
})
};
let broadcast_handle = {
let tx = harness.broadcast_tx.clone();
tokio::spawn(async move {
for i in 0..5 {
let msg = serde_json::json!({"ws_seq": i});
let _ = tx.send(serde_json::to_string(&msg).unwrap());
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
})
};
use futures_util::StreamExt;
let ws_handle = tokio::spawn(async move {
let mut count = 0;
loop {
match tokio::time::timeout(
std::time::Duration::from_secs(2),
ws_stream.next(),
)
.await
{
Ok(Some(Ok(_))) => count += 1,
_ => break,
}
if count >= 5 {
break;
}
}
assert!(count >= 5, "WS should receive at least 5 messages, got {count}");
});
rest_handle.await.unwrap();
broadcast_handle.await.unwrap();
ws_handle.await.unwrap();
}
#[tokio::test]
async fn test_health_repeated_is_idempotent() {
let base = setup().await;
for _ in 0..5 {
let res = reqwest::get(format!("{base}/v1/health")).await.unwrap();
assert_eq!(res.status(), 204);
}
}
#[tokio::test]
async fn test_about_repeated_is_consistent() {
let base = setup().await;
let res1 = reqwest::get(format!("{base}/v1/about")).await.unwrap();
let body1: serde_json::Value = res1.json().await.unwrap();
let res2 = reqwest::get(format!("{base}/v1/about")).await.unwrap();
let body2: serde_json::Value = res2.json().await.unwrap();
assert_eq!(body1, body2, "About should return consistent results");
}
#[tokio::test]
async fn test_send_returns_timestamp_consistently() {
let base = setup().await;
let client = reqwest::Client::new();
for _ in 0..3 {
let res = client
.post(format!("{base}/v2/send"))
.json(&serde_json::json!({
"message": "consistency",
"number": "+123",
"recipients": ["+999"]
}))
.send()
.await
.unwrap();
let body: serde_json::Value = res.json().await.unwrap();
assert_eq!(body["timestamp"], 1234567890);
}
}
#[tokio::test]
async fn test_special_chars_in_group_name() {
let base = setup().await;
assert_json_request(&base, "POST", "/v1/groups/+123", serde_json::json!({"name": "Group <with> \"special\" & chars 🎉", "members": ["+999"]}), 201).await;
}
#[tokio::test]
async fn test_url_encoded_chars_in_path() {
let base = setup().await;
let res = reqwest::get(format!("{base}/v1/groups/%2B123")).await.unwrap();
assert!(res.status().is_success() || res.status() == 400);
}
#[tokio::test]
async fn test_empty_json_body_on_send() {
let base = setup().await;
let client = reqwest::Client::new();
let res = client
.post(format!("{base}/v2/send"))
.header("content-type", "application/json")
.body("{}")
.send()
.await
.unwrap();
assert!(res.status().is_success() || res.status().is_client_error());
}
#[tokio::test]
async fn test_no_content_type_on_send() {
let base = setup().await;
let client = reqwest::Client::new();
let res = client
.post(format!("{base}/v2/send"))
.body("not json at all")
.send()
.await
.unwrap();
assert!(res.status().is_client_error());
}
#[tokio::test]
async fn test_invalid_json_body() {
let base = setup().await;
let client = reqwest::Client::new();
let res = client
.post(format!("{base}/v2/send"))
.header("content-type", "application/json")
.body("{invalid json}")
.send()
.await
.unwrap();
assert!(res.status().is_client_error());
}
#[tokio::test]
async fn test_groups_create_missing_required_fields() {
let base = setup().await;
let client = reqwest::Client::new();
let res = client
.post(format!("{base}/v1/groups/+123"))
.json(&serde_json::json!({"members": ["+999"]}))
.send()
.await
.unwrap();
assert!(res.status().is_client_error());
}
#[tokio::test]
async fn test_groups_create_missing_members() {
let base = setup().await;
let client = reqwest::Client::new();
let res = client
.post(format!("{base}/v1/groups/+123"))
.json(&serde_json::json!({"name": "No Members"}))
.send()
.await
.unwrap();
assert!(res.status().is_client_error());
}
#[tokio::test]
async fn test_accounts_pin_empty_body() {
let base = setup().await;
let client = reqwest::Client::new();
let res = client
.post(format!("{base}/v1/accounts/+123/pin"))
.header("content-type", "application/json")
.body("{}")
.send()
.await
.unwrap();
assert!(res.status().is_client_error());
}
#[tokio::test]
async fn test_webhooks_create_missing_url() {
let base = setup().await;
let client = reqwest::Client::new();
let res = client
.post(format!("{base}/v1/webhooks"))
.json(&serde_json::json!({"events": ["message"]}))
.send()
.await
.unwrap();
assert!(res.status().is_client_error());
}
#[tokio::test]
async fn test_device_link_missing_uri() {
let base = setup().await;
let client = reqwest::Client::new();
let res = client
.post(format!("{base}/v1/devices/+123"))
.json(&serde_json::json!({"device_name": "Test"}))
.send()
.await
.unwrap();
assert!(res.status().is_client_error());
}
#[tokio::test]
async fn test_qrcodelink_raw_returns_plain_text() {
let base = setup().await;
let res = reqwest::get(format!("{base}/v1/qrcodelink/raw")).await.unwrap();
let ct = res.headers().get("content-type").map(|v| v.to_str().unwrap().to_string());
if let Some(ct) = ct {
assert!(!ct.contains("application/json") || ct.contains("text/plain"),
"Raw endpoint should return plain text, got: {ct}");
}
}
#[tokio::test]
async fn test_qrcodelink_raw_contains_sgnl_uri() {
let base = setup().await;
let res = reqwest::get(format!("{base}/v1/qrcodelink/raw")).await.unwrap();
let body = res.text().await.unwrap();
assert!(body.contains("sgnl://"), "Raw QR code should contain sgnl:// URI, got: {body}");
}
#[tokio::test]
async fn test_error_does_not_affect_subsequent_requests() {
let base = setup().await;
let client = reqwest::Client::new();
let res = client
.post(format!("{base}/v2/send"))
.json(&serde_json::json!({
"message": "fail",
"number": "+ERROR",
"recipients": ["+999"]
}))
.send()
.await
.unwrap();
assert_eq!(res.status(), 400);
let res = client
.post(format!("{base}/v2/send"))
.json(&serde_json::json!({
"message": "succeed",
"number": "+123",
"recipients": ["+999"]
}))
.send()
.await
.unwrap();
assert_eq!(res.status(), 201);
let body: serde_json::Value = res.json().await.unwrap();
assert_eq!(body["timestamp"], 1234567890);
}
#[tokio::test]
async fn test_multiple_errors_in_sequence() {
let harness = setup_full().await;
let base = &harness.base_url;
let client = reqwest::Client::new();
for i in 0..5 {
let res = client
.post(format!("{base}/v2/send"))
.json(&serde_json::json!({
"message": format!("fail-{i}"),
"number": "+ERROR",
"recipients": ["+999"]
}))
.send()
.await
.unwrap();
assert_eq!(res.status(), 400, "Error request {i} should be 400");
}
let rpc_errors = harness.metrics.rpc_errors.load(std::sync::atomic::Ordering::Relaxed);
assert_eq!(rpc_errors, 5, "Should have exactly 5 RPC errors, got {rpc_errors}");
let res = reqwest::get(format!("{base}/v1/health")).await.unwrap();
assert_eq!(res.status(), 204);
}
#[tokio::test]
async fn test_openapi_info_metadata() {
let base = setup().await;
let res = reqwest::get(format!("{base}/v1/openapi.json")).await.unwrap();
let body: serde_json::Value = res.json().await.unwrap();
assert!(body["info"]["title"].as_str().is_some());
assert!(body["info"]["version"].as_str().is_some());
}
#[tokio::test]
async fn test_openapi_paths_have_methods() {
let base = setup().await;
let res = reqwest::get(format!("{base}/v1/openapi.json")).await.unwrap();
let body: serde_json::Value = res.json().await.unwrap();
let paths = body["paths"].as_object().unwrap();
for (path, methods) in paths {
let method_obj = methods.as_object().unwrap();
assert!(
!method_obj.is_empty(),
"Path {path} has no HTTP methods defined"
);
}
}
#[tokio::test]
async fn test_attachments_list_response_structure() {
let base = setup().await;
let body = assert_get(&base, "/v1/attachments", 200).await.unwrap();
for att in body.as_array().unwrap() {
assert!(att.get("id").is_some(), "Attachment should have 'id'");
assert!(att.get("filename").is_some(), "Attachment should have 'filename'");
}
}
#[tokio::test]
async fn test_attachments_get_by_id_response_structure() {
let base = setup().await;
let body = assert_get(&base, "/v1/attachments/att1", 200).await.unwrap();
assert_eq!(body["id"], "att1");
assert_eq!(body["filename"], "photo.jpg");
assert!(body["size"].as_u64().is_some(), "Attachment should have numeric size");
}
#[tokio::test]
async fn test_stickers_list_response_structure() {
let base = setup().await;
let body = assert_get(&base, "/v1/sticker-packs/+123", 200).await.unwrap();
for pack in body.as_array().unwrap() {
assert!(pack.get("packId").is_some(), "Sticker pack should have 'packId'");
assert!(pack.get("title").is_some(), "Sticker pack should have 'title'");
}
}
#[tokio::test]
async fn test_stickers_install_returns_pack_id() {
let base = setup().await;
let body = assert_json_request(&base, "POST", "/v1/sticker-packs/+123", serde_json::json!({"packId": "new-pack", "packKey": "secret-key"}), 201).await;
assert!(body.unwrap().get("packId").is_some());
}
#[tokio::test]
async fn test_search_response_structure() {
let base = setup().await;
let body = assert_get(&base, "/v1/search/+123?numbers=+1111", 200).await.unwrap();
for result in body.as_array().unwrap() {
assert!(result.get("number").is_some(), "Search result should have 'number'");
assert!(result.get("registered").is_some(), "Search result should have 'registered'");
}
}
#[tokio::test]
async fn test_reaction_to_group() {
let base = setup().await;
assert_json_request(&base, "POST", "/v1/reactions/+123", serde_json::json!({"recipient": "+999", "reaction": "🔥", "target_author": "+999", "timestamp": 12345, "group-id": "g1"}), 201).await;
}
#[tokio::test]
async fn test_receipt_to_group() {
let base = setup().await;
assert_json_request(&base, "POST", "/v1/receipts/+123", serde_json::json!({"receipt_type": "read", "recipient": "+999", "timestamp": 12345, "group-id": "g1"}), 200).await;
}
async fn start_hanging_mock() -> SocketAddr {
let listener = TokioTcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
let (stream, _) = listener.accept().await.unwrap();
tokio::spawn(async move {
let (reader, _writer) = stream.into_split();
let mut lines = BufReader::new(reader).lines();
while let Ok(Some(_)) = lines.next_line().await {}
});
}
});
addr
}
async fn setup_with_timeout(timeout: std::time::Duration) -> String {
let mock_addr = start_hanging_mock().await;
let stream = tokio::net::TcpStream::connect(mock_addr).await.unwrap();
let (reader, writer) = stream.into_split();
let (writer_tx, writer_rx) = tokio::sync::mpsc::channel::<String>(256);
tokio::spawn(signal_cli_api::jsonrpc::writer_loop(writer_rx, writer));
let mut state = signal_cli_api::state::AppState::new(writer_tx);
state.rpc_timeout = timeout;
let broadcast_tx = state.broadcast_tx.clone();
let pending = state.pending.clone();
let metrics = state.metrics.clone();
tokio::spawn(signal_cli_api::jsonrpc::reader_loop(
reader,
broadcast_tx,
pending,
metrics,
));
let app = signal_cli_api::routes::router(state).layer(CorsLayer::permissive());
let listener = TokioTcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
format!("http://{addr}")
}
#[tokio::test]
async fn test_rpc_timeout_returns_504() {
let base = setup_with_timeout(std::time::Duration::from_millis(200)).await;
let client = reqwest::Client::new();
let start = std::time::Instant::now();
let res = client
.post(format!("{base}/v2/send"))
.json(&serde_json::json!({
"message": "timeout test",
"number": "+111",
"recipients": ["+222"]
}))
.send()
.await
.unwrap();
let elapsed = start.elapsed();
assert!(elapsed < std::time::Duration::from_secs(2), "RPC call hung for {elapsed:?}");
assert_eq!(res.status(), 504, "Expected 504 Gateway Timeout, got {}", res.status());
}
#[tokio::test]
async fn test_rpc_timeout_does_not_affect_fast_responses() {
let base = setup_with_timeout(std::time::Duration::from_secs(5)).await;
let res = reqwest::get(format!("{base}/v1/health")).await.unwrap();
assert_eq!(res.status(), 204);
}
#[tokio::test]
async fn test_rpc_timeout_cleans_up_pending() {
let base = setup_with_timeout(std::time::Duration::from_millis(100)).await;
let client = reqwest::Client::new();
let _ = client
.post(format!("{base}/v2/send"))
.json(&serde_json::json!({
"message": "timeout",
"number": "+111",
"recipients": ["+222"]
}))
.send()
.await
.unwrap();
let res = reqwest::get(format!("{base}/v1/health")).await.unwrap();
assert_eq!(res.status(), 204);
}
async fn start_webhook_receiver() -> (SocketAddr, Arc<tokio::sync::Mutex<Vec<String>>>) {
let received = Arc::new(tokio::sync::Mutex::new(Vec::new()));
let received_clone = received.clone();
let app = axum::Router::new().route(
"/hook",
axum::routing::post(
move |body: axum::body::Bytes| {
let store = received_clone.clone();
async move {
let text = String::from_utf8_lossy(&body).to_string();
store.lock().await.push(text);
axum::http::StatusCode::OK
}
},
),
);
let listener = TokioTcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
(addr, received)
}
#[tokio::test]
async fn test_webhook_event_filter_allows_matching_events() {
let harness = setup_full().await;
let base = &harness.base_url;
let client = reqwest::Client::new();
let (receiver_addr, received) = start_webhook_receiver().await;
client
.post(format!("{base}/v1/webhooks"))
.json(&serde_json::json!({
"url": format!("http://{receiver_addr}/hook"),
"events": ["message"]
}))
.send()
.await
.unwrap();
let _ = harness.broadcast_tx.send(serde_json::json!({
"envelope": {
"source": "+111",
"dataMessage": { "message": "hello", "timestamp": 1 }
}
}).to_string());
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
let msgs = received.lock().await;
assert_eq!(msgs.len(), 1, "Expected 1 webhook delivery for matching event, got {}", msgs.len());
}
#[tokio::test]
async fn test_webhook_event_filter_blocks_non_matching_events() {
let harness = setup_full().await;
let base = &harness.base_url;
let client = reqwest::Client::new();
let (receiver_addr, received) = start_webhook_receiver().await;
client
.post(format!("{base}/v1/webhooks"))
.json(&serde_json::json!({
"url": format!("http://{receiver_addr}/hook"),
"events": ["receipt"]
}))
.send()
.await
.unwrap();
let _ = harness.broadcast_tx.send(serde_json::json!({
"envelope": {
"source": "+111",
"dataMessage": { "message": "hello", "timestamp": 1 }
}
}).to_string());
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
let msgs = received.lock().await;
assert_eq!(msgs.len(), 0, "Expected 0 deliveries for non-matching event, got {}", msgs.len());
}
#[tokio::test]
async fn test_webhook_empty_events_receives_everything() {
let harness = setup_full().await;
let base = &harness.base_url;
let client = reqwest::Client::new();
let (receiver_addr, received) = start_webhook_receiver().await;
client
.post(format!("{base}/v1/webhooks"))
.json(&serde_json::json!({
"url": format!("http://{receiver_addr}/hook")
}))
.send()
.await
.unwrap();
let _ = harness.broadcast_tx.send(serde_json::json!({
"envelope": {
"source": "+111",
"typingMessage": { "action": "STARTED" }
}
}).to_string());
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
let msgs = received.lock().await;
assert_eq!(msgs.len(), 1, "Webhook with empty events should receive everything");
}
#[tokio::test]
async fn test_contacts_sync_rpc_error() {
let base = setup().await;
assert_no_body_request(&base, "POST", "/v1/contacts/+ERROR/sync", 400).await;
}
#[tokio::test]
async fn test_groups_join_rpc_error() {
let base = setup().await;
assert_no_body_request(&base, "POST", "/v1/groups/+ERROR/g1/join", 400).await;
}
#[tokio::test]
async fn test_groups_quit_rpc_error() {
let base = setup().await;
assert_no_body_request(&base, "POST", "/v1/groups/+ERROR/g1/quit", 400).await;
}
#[tokio::test]
async fn test_groups_block_rpc_error() {
let base = setup().await;
assert_no_body_request(&base, "POST", "/v1/groups/+ERROR/g1/block", 400).await;
}
#[tokio::test]
async fn test_groups_add_members_rpc_error() {
let base = setup().await;
assert_json_request(&base, "POST", "/v1/groups/+ERROR/g1/members", serde_json::json!({"members": ["+111"]}), 400).await;
}
#[tokio::test]
async fn test_groups_remove_members_rpc_error() {
let base = setup().await;
assert_json_request(&base, "DELETE", "/v1/groups/+ERROR/g1/members", serde_json::json!({"members": ["+111"]}), 400).await;
}
#[tokio::test]
async fn test_groups_add_admins_rpc_error() {
let base = setup().await;
assert_json_request(&base, "POST", "/v1/groups/+ERROR/g1/admins", serde_json::json!({"admins": ["+111"]}), 400).await;
}
#[tokio::test]
async fn test_groups_remove_admins_rpc_error() {
let base = setup().await;
assert_json_request(&base, "DELETE", "/v1/groups/+ERROR/g1/admins", serde_json::json!({"admins": ["+111"]}), 400).await;
}
#[tokio::test]
async fn test_config_set_global_rpc_error() {
let base = setup().await;
assert_json_request(&base, "POST", "/v1/configuration", serde_json::json!({"account": "+ERROR", "trustMode": "always"}), 400).await;
}
#[tokio::test]
async fn test_config_set_account_rpc_error() {
let base = setup().await;
assert_json_request(&base, "POST", "/v1/configuration/+ERROR/settings", serde_json::json!({"trustMode": "always"}), 400).await;
}
#[tokio::test]
async fn test_identities_trust_rpc_error() {
let base = setup().await;
assert_json_request(&base, "PUT", "/v1/identities/+ERROR/trust/+999", serde_json::json!({"trust_all_known_keys": true}), 400).await;
}
#[tokio::test]
async fn test_accounts_set_pin_rpc_error() {
let base = setup().await;
assert_json_request(&base, "POST", "/v1/accounts/+ERROR/pin", serde_json::json!({"pin": "1234"}), 400).await;
}
#[tokio::test]
async fn test_accounts_remove_pin_rpc_error() {
let base = setup().await;
assert_no_body_request(&base, "DELETE", "/v1/accounts/+ERROR/pin", 400).await;
}
#[tokio::test]
async fn test_accounts_set_username_rpc_error() {
let base = setup().await;
assert_json_request(&base, "POST", "/v1/accounts/+ERROR/username", serde_json::json!({"username": "testuser"}), 400).await;
}
#[tokio::test]
async fn test_accounts_remove_username_rpc_error() {
let base = setup().await;
assert_no_body_request(&base, "DELETE", "/v1/accounts/+ERROR/username", 400).await;
}
#[tokio::test]
async fn test_polls_vote_rpc_error() {
let base = setup().await;
assert_json_request(&base, "POST", "/v1/polls/+ERROR/vote", serde_json::json!({"recipient": "+999", "poll_id": "p1", "options": [0]}), 400).await;
}
#[tokio::test]
async fn test_polls_close_rpc_error() {
let base = setup().await;
assert_json_request(&base, "DELETE", "/v1/polls/+ERROR", serde_json::json!({"recipient": "+999", "poll_id": "p1"}), 400).await;
}
#[tokio::test]
async fn test_stickers_install_rpc_error() {
let base = setup().await;
assert_json_request(&base, "POST", "/v1/sticker-packs/+ERROR", serde_json::json!({"pack_id": "abc", "pack_key": "def"}), 400).await;
}
#[tokio::test]
async fn test_contacts_get_single_rpc_error() {
let base = setup().await;
assert_get(&base, "/v1/contacts/+ERROR/+1111", 400).await;
}
#[tokio::test]
async fn test_contacts_update_rpc_error() {
let base = setup().await;
assert_json_request(&base, "PUT", "/v1/contacts/+ERROR", serde_json::json!({"name": "Bob", "recipient": "+999"}), 400).await;
}
#[tokio::test]
async fn test_devices_link_rpc_error() {
let base = setup().await;
assert_json_request(&base, "POST", "/v1/devices/+ERROR", serde_json::json!({"uri": "sgnl://linkdevice?uuid=test"}), 400).await;
}
#[tokio::test]
async fn test_devices_remove_rpc_error() {
let base = setup().await;
assert_no_body_request(&base, "DELETE", "/v1/devices/+ERROR/1", 400).await;
}
#[tokio::test]
async fn test_devices_delete_local_data_rpc_error() {
let base = setup().await;
assert_no_body_request(&base, "DELETE", "/v1/devices/+ERROR/local-data", 400).await;
}
#[tokio::test]
async fn test_accounts_register_rpc_error() {
let base = setup().await;
assert_json_request(&base, "POST", "/v1/register/+ERROR", serde_json::json!({}), 400).await;
}
#[tokio::test]
async fn test_accounts_verify_rpc_error() {
let base = setup().await;
assert_no_body_request(&base, "POST", "/v1/register/+ERROR/verify/123456", 400).await;
}
#[tokio::test]
async fn test_accounts_unregister_rpc_error() {
let base = setup().await;
assert_no_body_request(&base, "POST", "/v1/unregister/+ERROR", 400).await;
}
#[tokio::test]
async fn test_accounts_rate_limit_rpc_error() {
let base = setup().await;
assert_json_request(&base, "POST", "/v1/accounts/+ERROR/rate-limit-challenge", serde_json::json!({"challenge": "abc", "captcha": "def"}), 400).await;
}
#[tokio::test]
async fn test_accounts_update_settings_rpc_error() {
let base = setup().await;
assert_json_request(&base, "PUT", "/v1/accounts/+ERROR/settings", serde_json::json!({"trust_mode": "always"}), 400).await;
}
#[tokio::test]
async fn test_reaction_remove_rpc_error() {
let base = setup().await;
assert_json_request(&base, "DELETE", "/v1/reactions/+ERROR", serde_json::json!({"recipient": "+999", "reaction": "👍", "target_author": "+999", "timestamp": 12345}), 400).await;
}
#[tokio::test]
async fn test_typing_stop_rpc_error() {
let base = setup().await;
assert_json_request(&base, "DELETE", "/v1/typing-indicator/+ERROR", serde_json::json!({"recipient": "+999"}), 400).await;
}
#[tokio::test]
async fn test_empty_body_on_reactions() {
let base = setup().await;
let client = reqwest::Client::new();
let res = client
.post(format!("{base}/v1/reactions/+123"))
.header("content-type", "application/json")
.body("{}")
.send()
.await
.unwrap();
assert!(res.status().is_success() || res.status().is_client_error());
}
#[tokio::test]
async fn test_wrong_type_group_members_as_string() {
let base = setup().await;
let client = reqwest::Client::new();
let res = client
.post(format!("{base}/v1/groups/+123"))
.json(&serde_json::json!({
"name": "Test",
"members": "not-an-array"
}))
.send()
.await
.unwrap();
assert_eq!(res.status(), 422);
}
#[tokio::test]
async fn test_wrong_type_pin_as_number() {
let base = setup().await;
let client = reqwest::Client::new();
let res = client
.post(format!("{base}/v1/accounts/+123/pin"))
.json(&serde_json::json!({"pin": 1234}))
.send()
.await
.unwrap();
assert_eq!(res.status(), 422);
}
#[tokio::test]
async fn test_wrong_type_webhook_url_as_number() {
let base = setup().await;
let client = reqwest::Client::new();
let res = client
.post(format!("{base}/v1/webhooks"))
.json(&serde_json::json!({"url": 12345}))
.send()
.await
.unwrap();
assert_eq!(res.status(), 422);
}
#[tokio::test]
async fn test_missing_content_type_on_group_create() {
let base = setup().await;
let client = reqwest::Client::new();
let res = client
.post(format!("{base}/v1/groups/+123"))
.body(r#"{"name":"Test","members":["+999"]}"#)
.send()
.await
.unwrap();
assert_eq!(res.status(), 415);
}
#[tokio::test]
async fn test_empty_string_phone_number_in_send() {
let base = setup().await;
let client = reqwest::Client::new();
let res = client
.post(format!("{base}/v2/send"))
.json(&serde_json::json!({
"message": "hello",
"number": "",
"recipients": ["+999"]
}))
.send()
.await
.unwrap();
assert!(res.status().is_success());
}
#[tokio::test]
async fn test_empty_string_group_name() {
let base = setup().await;
let client = reqwest::Client::new();
let res = client
.post(format!("{base}/v1/groups/+123"))
.json(&serde_json::json!({
"name": "",
"members": ["+999"]
}))
.send()
.await
.unwrap();
assert_eq!(res.status(), 201);
}
#[tokio::test]
async fn test_null_body_on_post() {
let base = setup().await;
let client = reqwest::Client::new();
let res = client
.post(format!("{base}/v2/send"))
.header("content-type", "application/json")
.body("null")
.send()
.await
.unwrap();
assert!(res.status().is_success());
}
#[tokio::test]
async fn test_array_body_where_object_expected() {
let base = setup().await;
let client = reqwest::Client::new();
let res = client
.post(format!("{base}/v1/groups/+123"))
.json(&serde_json::json!(["+999"]))
.send()
.await
.unwrap();
assert_eq!(res.status(), 422);
}
#[tokio::test]
async fn test_extremely_large_json_body() {
let base = setup().await;
let client = reqwest::Client::new();
let big_msg = "x".repeat(100_000);
let res = client
.post(format!("{base}/v2/send"))
.json(&serde_json::json!({
"message": big_msg,
"number": "+123",
"recipients": ["+999"]
}))
.send()
.await
.unwrap();
assert_eq!(res.status(), 201);
}
#[tokio::test]
async fn test_webhook_unreachable_url_does_not_crash() {
let harness = setup_full().await;
let base = &harness.base_url;
let client = reqwest::Client::new();
client
.post(format!("{base}/v1/webhooks"))
.json(&serde_json::json!({
"url": "http://127.0.0.1:1/nonexistent"
}))
.send()
.await
.unwrap();
let _ = harness.broadcast_tx.send(serde_json::json!({
"envelope": {
"source": "+111",
"dataMessage": { "message": "hello", "timestamp": 1 }
}
}).to_string());
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
let res = reqwest::get(format!("{base}/v1/health")).await.unwrap();
assert_eq!(res.status(), 204);
}
#[tokio::test]
async fn test_webhook_one_fails_others_receive() {
let harness = setup_full().await;
let base = &harness.base_url;
let client = reqwest::Client::new();
let (receiver_addr, received) = start_webhook_receiver().await;
client
.post(format!("{base}/v1/webhooks"))
.json(&serde_json::json!({
"url": "http://127.0.0.1:1/broken"
}))
.send()
.await
.unwrap();
client
.post(format!("{base}/v1/webhooks"))
.json(&serde_json::json!({
"url": format!("http://{receiver_addr}/hook")
}))
.send()
.await
.unwrap();
let _ = harness.broadcast_tx.send(serde_json::json!({
"envelope": {
"source": "+111",
"dataMessage": { "message": "hello", "timestamp": 1 }
}
}).to_string());
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
let msgs = received.lock().await;
assert_eq!(msgs.len(), 1, "Working webhook should still receive despite broken one");
}
#[tokio::test]
async fn test_webhook_receipt_event_type() {
let harness = setup_full().await;
let base = &harness.base_url;
let client = reqwest::Client::new();
let (receiver_addr, received) = start_webhook_receiver().await;
client
.post(format!("{base}/v1/webhooks"))
.json(&serde_json::json!({
"url": format!("http://{receiver_addr}/hook"),
"events": ["receipt"]
}))
.send()
.await
.unwrap();
let _ = harness.broadcast_tx.send(serde_json::json!({
"envelope": {
"source": "+111",
"receiptMessage": { "type": "DELIVERY", "timestamps": [1234] }
}
}).to_string());
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
let msgs = received.lock().await;
assert_eq!(msgs.len(), 1, "Receipt event should pass through receipt filter");
}
#[tokio::test]
async fn test_webhook_typing_event_type() {
let harness = setup_full().await;
let base = &harness.base_url;
let client = reqwest::Client::new();
let (receiver_addr, received) = start_webhook_receiver().await;
client
.post(format!("{base}/v1/webhooks"))
.json(&serde_json::json!({
"url": format!("http://{receiver_addr}/hook"),
"events": ["typing"]
}))
.send()
.await
.unwrap();
let _ = harness.broadcast_tx.send(serde_json::json!({
"envelope": {
"source": "+111",
"typingMessage": { "action": "STARTED" }
}
}).to_string());
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
let msgs = received.lock().await;
assert_eq!(msgs.len(), 1, "Typing event should pass through typing filter");
}
#[tokio::test]
async fn test_sse_multiple_clients_receive_same_event() {
let harness = setup_full().await;
let base = &harness.base_url;
let client1 = reqwest::Client::new();
let client2 = reqwest::Client::new();
let resp1 = client1
.get(format!("{base}/v1/events/+123"))
.send()
.await
.unwrap();
let resp2 = client2
.get(format!("{base}/v1/events/+456"))
.send()
.await
.unwrap();
assert_eq!(resp1.status(), 200);
assert_eq!(resp2.status(), 200);
let _ = harness.broadcast_tx.send(r#"{"test":"multi-sse"}"#.to_string());
let body1 = tokio::time::timeout(
std::time::Duration::from_millis(500),
resp1.text(),
)
.await;
let body2 = tokio::time::timeout(
std::time::Duration::from_millis(500),
resp2.text(),
)
.await;
assert!(body1.is_ok() || body1.is_err()); assert!(body2.is_ok() || body2.is_err());
}
#[tokio::test]
async fn test_sse_content_type() {
let base = setup().await;
let client = reqwest::Client::new();
let res = client
.get(format!("{base}/v1/events/+123"))
.timeout(std::time::Duration::from_millis(200))
.send()
.await
.unwrap();
assert_eq!(res.status(), 200);
let ct = res.headers().get("content-type").unwrap().to_str().unwrap();
assert!(ct.contains("text/event-stream"), "SSE should have text/event-stream content type, got {ct}");
}