use std::path::PathBuf;
use std::time::Duration;
use tokio::time::timeout;
use truffle_core::network::tailscale::{TailscaleConfig, TailscaleProvider};
use truffle_core::network::{NetworkError, NetworkPeerEvent, NetworkProvider};
const KNOWN_PEER_IP: &str = "100.126.82.98";
const START_TIMEOUT: Duration = Duration::from_secs(30);
const OP_TIMEOUT: Duration = Duration::from_secs(15);
fn make_config() -> TailscaleConfig {
let test_id = uuid::Uuid::new_v4();
let short_id = &test_id.to_string()[..8];
TailscaleConfig {
binary_path: PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/test-sidecar"),
hostname: format!("truffle-test-integ-{short_id}"),
state_dir: format!("/tmp/truffle-test-{test_id}"),
auth_key: std::env::var("TS_AUTHKEY").ok(),
ephemeral: Some(true),
tags: None,
}
}
fn cleanup_state_dir(state_dir: &str) {
let _ = std::fs::remove_dir_all(state_dir);
}
async fn start_provider(provider: &mut TailscaleProvider) -> Result<(), NetworkError> {
match provider.start().await {
Ok(()) => Ok(()),
Err(NetworkError::AuthRequired { url }) => {
eprintln!("\n╔══════════════════════════════════════════════════════════════╗");
eprintln!("║ TAILSCALE AUTH REQUIRED ║");
eprintln!("║ Open this URL in your browser to authorize: ║");
eprintln!("║ {url}");
eprintln!("╚══════════════════════════════════════════════════════════════╝\n");
Err(NetworkError::AuthRequired { url })
}
Err(e) => Err(e),
}
}
#[tokio::test]
#[ignore = "requires real Tailscale network and Go sidecar binary"]
async fn test_provider_start_and_auth() {
tracing_subscriber::fmt()
.with_env_filter("truffle_core=debug,integration_network=debug")
.with_test_writer()
.try_init()
.ok();
let config = make_config();
let state_dir = config.state_dir.clone();
let mut provider = TailscaleProvider::new(config);
let result = timeout(START_TIMEOUT, start_provider(&mut provider)).await;
match result {
Ok(Ok(())) => {
let identity = provider.local_identity_async().await;
println!(" Provider started successfully");
println!(" Hostname: {}", identity.hostname);
println!(" DNS name: {:?}", identity.dns_name);
println!(" IP: {:?}", identity.ip);
assert!(
identity.ip.is_some(),
"provider should have a Tailscale IP after starting"
);
assert!(
!identity.hostname.is_empty(),
"provider should have a hostname after starting"
);
let health = provider.health().await;
println!(" Health state: {}", health.state);
println!(" Healthy: {}", health.healthy);
assert_eq!(health.state, "running");
assert!(health.healthy);
provider.stop().await.expect("stop should succeed");
}
Ok(Err(NetworkError::AuthRequired { .. })) => {
println!(" Auth required — test cannot proceed without TS_AUTHKEY or manual auth");
}
Ok(Err(e)) => {
panic!("Provider start failed unexpectedly: {e}");
}
Err(_) => {
panic!("Provider start timed out after {START_TIMEOUT:?}");
}
}
cleanup_state_dir(&state_dir);
}
#[tokio::test]
#[ignore = "requires real Tailscale network and Go sidecar binary"]
async fn test_peer_discovery() {
tracing_subscriber::fmt()
.with_env_filter("truffle_core=debug,integration_network=debug")
.with_test_writer()
.try_init()
.ok();
let config = make_config();
let state_dir = config.state_dir.clone();
let mut provider = TailscaleProvider::new(config);
let start_result = timeout(START_TIMEOUT, start_provider(&mut provider)).await;
if matches!(start_result, Ok(Err(NetworkError::AuthRequired { .. })) | Err(_)) {
println!(" Skipping: provider did not start (auth required or timeout)");
cleanup_state_dir(&state_dir);
return;
}
start_result
.expect("no timeout")
.expect("start should succeed");
tokio::time::sleep(Duration::from_secs(3)).await;
let peers = provider.peers().await;
println!(" Discovered {} truffle peer(s):", peers.len());
for peer in &peers {
println!(
" - {} (id={}, ip={}, online={}, dns={:?})",
peer.hostname, peer.id, peer.ip, peer.online, peer.dns_name
);
}
if peers.is_empty() {
println!(" WARNING: No truffle-* peers found. This is expected if no other truffle nodes are online.");
println!(" The network layer is working — just no truffle peers to discover.");
} else {
assert!(
!peers.is_empty(),
"expected at least 1 truffle peer on the tailnet"
);
}
provider.stop().await.expect("stop should succeed");
cleanup_state_dir(&state_dir);
}
#[tokio::test]
#[ignore = "requires real Tailscale network and Go sidecar binary"]
async fn test_peer_events() {
tracing_subscriber::fmt()
.with_env_filter("truffle_core=debug,integration_network=debug")
.with_test_writer()
.try_init()
.ok();
let config = make_config();
let state_dir = config.state_dir.clone();
let mut provider = TailscaleProvider::new(config);
let mut event_rx = provider.peer_events();
let start_result = timeout(START_TIMEOUT, start_provider(&mut provider)).await;
if matches!(start_result, Ok(Err(NetworkError::AuthRequired { .. })) | Err(_)) {
println!(" Skipping: provider did not start (auth required or timeout)");
cleanup_state_dir(&state_dir);
return;
}
start_result
.expect("no timeout")
.expect("start should succeed");
let mut events = Vec::new();
let collect_deadline = tokio::time::Instant::now() + Duration::from_secs(5);
loop {
let remaining = collect_deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
break;
}
match timeout(remaining, event_rx.recv()).await {
Ok(Ok(event)) => {
events.push(event);
}
Ok(Err(tokio::sync::broadcast::error::RecvError::Lagged(n))) => {
println!(" Event receiver lagged by {n} events");
}
_ => break,
}
}
println!(" Received {} peer event(s) in 5s:", events.len());
for event in &events {
match event {
NetworkPeerEvent::Joined(peer) => {
println!(" + Joined: {} (ip={}, online={})", peer.hostname, peer.ip, peer.online);
}
NetworkPeerEvent::Left(id) => {
println!(" - Left: {id}");
}
NetworkPeerEvent::Updated(peer) => {
println!(" ~ Updated: {} (ip={}, online={})", peer.hostname, peer.ip, peer.online);
}
NetworkPeerEvent::AuthRequired { url } => {
println!(" ! Auth required: {url}");
}
}
}
println!(" Event stream is working (received {} events)", events.len());
provider.stop().await.expect("stop should succeed");
cleanup_state_dir(&state_dir);
}
#[tokio::test]
#[ignore = "requires real Tailscale network and Go sidecar binary"]
async fn test_dial_tcp() {
tracing_subscriber::fmt()
.with_env_filter("truffle_core=debug,integration_network=debug")
.with_test_writer()
.try_init()
.ok();
let config = make_config();
let state_dir = config.state_dir.clone();
let mut provider = TailscaleProvider::new(config);
let start_result = timeout(START_TIMEOUT, start_provider(&mut provider)).await;
if matches!(start_result, Ok(Err(NetworkError::AuthRequired { .. })) | Err(_)) {
println!(" Skipping: provider did not start (auth required or timeout)");
cleanup_state_dir(&state_dir);
return;
}
start_result
.expect("no timeout")
.expect("start should succeed");
println!(" Dialing {KNOWN_PEER_IP}:22 (SSH)...");
let dial_result = timeout(OP_TIMEOUT, provider.dial_tcp(KNOWN_PEER_IP, 22)).await;
match dial_result {
Ok(Ok(stream)) => {
println!(" Successfully dialed! Remote addr: {:?}", stream.peer_addr());
let mut buf = [0u8; 256];
let read_result = timeout(Duration::from_secs(5), stream.readable()).await;
match read_result {
Ok(Ok(())) => {
match stream.try_read(&mut buf) {
Ok(n) if n > 0 => {
let banner = String::from_utf8_lossy(&buf[..n]);
println!(" SSH banner: {}", banner.trim());
assert!(
banner.contains("SSH"),
"expected SSH banner from port 22, got: {banner}"
);
}
Ok(_) => {
println!(" Connection established but no data read (may be expected)");
}
Err(e) => {
println!(" try_read error (non-fatal): {e}");
}
}
}
Ok(Err(e)) => {
println!(" readable() error (non-fatal): {e}");
}
Err(_) => {
println!(" Timed out waiting for SSH banner (connection still valid)");
}
}
}
Ok(Err(e)) => {
panic!("Dial to {KNOWN_PEER_IP}:22 failed: {e}");
}
Err(_) => {
panic!("Dial to {KNOWN_PEER_IP}:22 timed out after {OP_TIMEOUT:?}");
}
}
provider.stop().await.expect("stop should succeed");
cleanup_state_dir(&state_dir);
}
#[tokio::test]
#[ignore = "requires real Tailscale network and Go sidecar binary"]
async fn test_ping() {
tracing_subscriber::fmt()
.with_env_filter("truffle_core=debug,integration_network=debug")
.with_test_writer()
.try_init()
.ok();
let config = make_config();
let state_dir = config.state_dir.clone();
let mut provider = TailscaleProvider::new(config);
let start_result = timeout(START_TIMEOUT, start_provider(&mut provider)).await;
if matches!(start_result, Ok(Err(NetworkError::AuthRequired { .. })) | Err(_)) {
println!(" Skipping: provider did not start (auth required or timeout)");
cleanup_state_dir(&state_dir);
return;
}
start_result
.expect("no timeout")
.expect("start should succeed");
println!(" Pinging {KNOWN_PEER_IP}...");
let ping_result = timeout(OP_TIMEOUT, provider.ping(KNOWN_PEER_IP)).await;
match ping_result {
Ok(Ok(result)) => {
println!(" Ping successful!");
println!(" Latency: {:?}", result.latency);
println!(" Connection: {}", result.connection);
println!(" Peer addr: {:?}", result.peer_addr);
assert!(
result.latency > Duration::ZERO,
"ping latency should be > 0, got {:?}",
result.latency
);
assert!(
!result.connection.is_empty(),
"ping connection type should not be empty"
);
}
Ok(Err(e)) => {
panic!("Ping to {KNOWN_PEER_IP} failed: {e}");
}
Err(_) => {
panic!("Ping to {KNOWN_PEER_IP} timed out after {OP_TIMEOUT:?}");
}
}
provider.stop().await.expect("stop should succeed");
cleanup_state_dir(&state_dir);
}
#[tokio::test]
#[ignore = "requires real Tailscale network and Go sidecar binary"]
async fn test_health() {
tracing_subscriber::fmt()
.with_env_filter("truffle_core=debug,integration_network=debug")
.with_test_writer()
.try_init()
.ok();
let config = make_config();
let state_dir = config.state_dir.clone();
let mut provider = TailscaleProvider::new(config);
let start_result = timeout(START_TIMEOUT, start_provider(&mut provider)).await;
if matches!(start_result, Ok(Err(NetworkError::AuthRequired { .. })) | Err(_)) {
println!(" Skipping: provider did not start (auth required or timeout)");
cleanup_state_dir(&state_dir);
return;
}
start_result
.expect("no timeout")
.expect("start should succeed");
let health = provider.health().await;
println!(" Health info:");
println!(" State: {}", health.state);
println!(" Healthy: {}", health.healthy);
println!(" Key expiry: {:?}", health.key_expiry);
println!(" Warnings: {:?}", health.warnings);
assert_eq!(
health.state, "running",
"health state should be 'running' after successful start"
);
assert!(
health.healthy,
"health should report healthy after successful start"
);
provider.stop().await.expect("stop should succeed");
let health_after_stop = provider.health().await;
println!(" Health after stop:");
println!(" State: {}", health_after_stop.state);
println!(" Healthy: {}", health_after_stop.healthy);
assert_eq!(
health_after_stop.state, "stopped",
"health state should be 'stopped' after stop"
);
assert!(
!health_after_stop.healthy,
"health should report unhealthy after stop"
);
cleanup_state_dir(&state_dir);
}