use std::sync::Arc;
use axum::{
body::Body,
http::{Request, StatusCode},
};
use ilink_hub::{
hub::{AdminConfig, HubState},
ilink::UpstreamClient,
server::build_router,
store::Store,
InMemoryQueue,
};
use tower::ServiceExt;
async fn make_state() -> Arc<HubState> {
let store = Store::connect("sqlite::memory:")
.await
.expect("in-memory store");
let upstream =
Arc::new(UpstreamClient::new("sk-test".to_string(), None).expect("test upstream client"));
let queue = Arc::new(InMemoryQueue::new());
let (_tx, shutdown_rx) = tokio::sync::watch::channel(false);
HubState::new(
upstream,
Arc::new(store),
queue,
shutdown_rx,
"test-relay-secret".to_string(),
AdminConfig::from_env(),
)
}
#[tokio::test]
async fn admin_clients_requires_auth_when_no_token_configured() {
if std::env::var("ILINK_ADMIN_TOKEN").is_ok() {
eprintln!("SKIP: ILINK_ADMIN_TOKEN is set in environment, skipping this test");
return;
}
if std::env::var("ILINK_ADMIN_INSECURE_NO_AUTH")
.ok()
.map(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes"))
.unwrap_or(false)
{
eprintln!("SKIP: ILINK_ADMIN_INSECURE_NO_AUTH is set, skipping this test");
return;
}
let state = make_state().await;
let app = build_router(state);
let resp = app
.oneshot(
Request::builder()
.method("GET")
.uri("/hub/clients")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::UNAUTHORIZED,
"GET /hub/clients should return 401 when no token is configured \
(new secure default — set ILINK_ADMIN_INSECURE_NO_AUTH=true to allow unauthenticated access)"
);
}
#[tokio::test]
async fn admin_register_requires_auth() {
if std::env::var("ILINK_ADMIN_TOKEN").is_ok() {
return;
}
if std::env::var("ILINK_ADMIN_INSECURE_NO_AUTH")
.ok()
.map(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes"))
.unwrap_or(false)
{
return;
}
let state = make_state().await;
let app = build_router(state);
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/hub/register")
.header("Content-Type", "application/json")
.body(Body::from(r#"{"name":"test"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn admin_endpoint_accessible_with_correct_token() {
if std::env::var("ILINK_ADMIN_TOKEN").is_ok() {
return; }
if std::env::var("ILINK_ADMIN_INSECURE_NO_AUTH")
.ok()
.map(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes"))
.unwrap_or(false)
{
return;
}
let state = make_state().await;
let app = build_router(state);
let resp = app
.oneshot(
Request::builder()
.method("GET")
.uri("/hub/clients")
.header("Authorization", "Bearer wrong-token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn migration_creates_expected_schema_on_fresh_db() {
let store = Store::connect("sqlite::memory:")
.await
.expect("migration should succeed on fresh in-memory database");
use ilink_hub::store::Store;
let result = store.list_clients().await;
assert!(result.is_ok(), "clients table should exist after migration");
let result = store
.find_or_create_vctx("test-user", None, "real-ctx")
.await;
assert!(
result.is_ok(),
"context_token_map table should exist after migration"
);
}
#[tokio::test]
async fn migration_is_safe_to_run_twice() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let url = format!("sqlite:{}", tmp.path().display());
let _store1 = Store::connect(&url)
.await
.expect("first connect + migrate should succeed");
let _store2 = Store::connect(&url)
.await
.expect("second connect on same DB should succeed (idempotent migrations)");
}
#[tokio::test]
async fn find_or_create_vctx_creates_stable_entries() {
let store = Store::connect("sqlite::memory:")
.await
.expect("in-memory store");
for i in 0..5 {
store
.find_or_create_vctx(&format!("user_{i}"), None, &format!("real_{i}"))
.await
.unwrap();
}
for i in 0..5 {
let v1 = store
.find_or_create_vctx(&format!("user_{i}"), None, &format!("real_{i}"))
.await
.unwrap();
let v2 = store
.find_or_create_vctx(&format!("user_{i}"), None, &format!("real_{i}_new"))
.await
.unwrap();
assert_eq!(v1, v2, "same peer should always get the same vctx");
}
}
#[test]
fn queue_backend_memory_is_unchanged() {
assert!(
std::env::var("ILINK_QUEUE_BACKEND").unwrap_or_default() != "redis",
"ILINK_QUEUE_BACKEND should not be set to 'redis' in test environment"
);
}
#[tokio::test]
async fn pair_confirm_handler_rejects_no_origin_no_referer() {
use std::net::SocketAddr;
let state = make_state().await;
let app = ilink_hub::server::build_router(state);
let mut req = Request::builder()
.method("POST")
.uri("/hub/pair/pair_fm1b_no_origin/confirm")
.header("Content-Type", "application/json")
.header("X-Pair-CSRF", "deadbeef".repeat(4))
.body(Body::from(serde_json::json!({"name": "alice"}).to_string()))
.unwrap();
req.extensions_mut()
.insert(axum::extract::ConnectInfo::<SocketAddr>(
"127.0.0.1:55555".parse().unwrap(),
));
let resp = app.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::FORBIDDEN,
"F-M1-B: pair_confirm with no Origin/Referer must be rejected (got {})",
resp.status()
);
}
#[tokio::test]
async fn pair_confirm_handler_rejects_foreign_origin() {
use std::net::SocketAddr;
let state = make_state().await;
let app = ilink_hub::server::build_router(state);
let mut req = Request::builder()
.method("POST")
.uri("/hub/pair/pair_fm1b_foreign_origin/confirm")
.header("Content-Type", "application/json")
.header("X-Pair-CSRF", "deadbeef".repeat(4))
.header("Origin", "https://attacker.example.com")
.body(Body::from(serde_json::json!({"name": "alice"}).to_string()))
.unwrap();
req.extensions_mut()
.insert(axum::extract::ConnectInfo::<SocketAddr>(
"127.0.0.1:55555".parse().unwrap(),
));
let resp = app.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::FORBIDDEN,
"F-M1-B: pair_confirm with foreign Origin must be rejected (got {})",
resp.status()
);
}
#[tokio::test]
async fn sendtyping_error_propagation_test() {
use axum::routing::post;
use axum::Router;
use std::sync::atomic::{AtomicBool, Ordering};
let should_fail = Arc::new(AtomicBool::new(false));
let should_fail_clone = should_fail.clone();
let mock_app = Router::new().route(
"/ilink/bot/sendtyping",
post(move || {
let sf = should_fail_clone.clone();
async move {
if sf.load(Ordering::Relaxed) {
(axum::http::StatusCode::INTERNAL_SERVER_ERROR, "mock error")
} else {
(axum::http::StatusCode::OK, "")
}
}
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let base_url = format!("http://{}", addr);
tokio::spawn(async move {
axum::serve(listener, mock_app).await.unwrap();
});
let store = Store::connect("sqlite::memory:")
.await
.expect("in-memory store");
let upstream = Arc::new(
UpstreamClient::new("sk-test:key".to_string(), Some(base_url))
.expect("test upstream client"),
);
let queue = Arc::new(InMemoryQueue::new());
let (_tx, shutdown_rx) = tokio::sync::watch::channel(false);
let state = HubState::new(
upstream,
Arc::new(store),
queue,
shutdown_rx,
"test-relay-secret".to_string(),
AdminConfig::from_env(),
);
let outcome = ilink_hub::server::pairing::register_client_in_hub(
&state,
"test-client".to_string(),
None,
None,
)
.await;
let vtoken = outcome.plaintext;
let app = build_router(state);
should_fail.store(false, Ordering::Relaxed);
let req = Request::builder()
.method("POST")
.uri("/ilink/bot/sendtyping")
.header("Authorization", format!("Bearer {vtoken}"))
.header("Content-Type", "application/json")
.body(Body::from(r#"{"vctx":"vctx-123","typing":true}"#))
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body_bytes = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
assert_eq!(json["ret"], 0);
should_fail.store(true, Ordering::Relaxed);
let req = Request::builder()
.method("POST")
.uri("/ilink/bot/sendtyping")
.header("Authorization", format!("Bearer {vtoken}"))
.header("Content-Type", "application/json")
.body(Body::from(r#"{"vctx":"vctx-123","typing":true}"#))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body_bytes = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
assert_eq!(json["ret"], 500);
assert!(json["errmsg"].as_str().unwrap().contains("upstream error"));
}
#[tokio::test]
async fn sendtyping_upstream_network_error_propagates() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
drop(listener);
let base_url = format!("http://{}", addr);
let upstream = UpstreamClient::new("sk-test:key".to_string(), Some(base_url))
.expect("test upstream client");
let req = ilink_hub::ilink::SendTypingRequest {
status: Some(1),
..Default::default()
};
let err = upstream
.send_typing(req)
.await
.expect_err("send_typing against a closed port must return Err, not Ok");
let msg = format!("{err:#}");
assert!(
msg.contains("error sending request")
|| msg.contains("connection refused")
|| msg.contains("Connection refused"),
"expected transport-level error, got: {msg}",
);
}
#[tokio::test]
async fn sendtyping_upstream_http_500_propagates() {
use axum::routing::post;
use axum::Router;
let mock_app = Router::new().route(
"/ilink/bot/sendtyping",
post(|| async { (axum::http::StatusCode::INTERNAL_SERVER_ERROR, "mock 500") }),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let base_url = format!("http://{}", addr);
tokio::spawn(async move {
axum::serve(listener, mock_app).await.unwrap();
});
let upstream = UpstreamClient::new("sk-test:key".to_string(), Some(base_url))
.expect("test upstream client");
let req = ilink_hub::ilink::SendTypingRequest {
status: Some(1),
..Default::default()
};
let err = upstream
.send_typing(req)
.await
.expect_err("send_typing against a 500 mock must return Err");
let msg = format!("{err:#}");
assert!(
msg.contains("500") || msg.to_lowercase().contains("server error"),
"expected HTTP 500 to surface in error chain, got: {msg}",
);
}
#[test]
fn test_cli_default_listen_address_is_loopback() {
let bin_path = std::env::var("CARGO_BIN_EXE_ilink-hub")
.unwrap_or_else(|_| "./target/release/ilink-hub".to_string());
let output = std::process::Command::new(bin_path)
.arg("serve")
.arg("--help")
.env_clear()
.output()
.expect("failed to execute ilink-hub binary");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("default: \"127.0.0.1:8765\"")
|| stdout.contains("default_value = \"127.0.0.1:8765\"")
|| stdout.contains("127.0.0.1:8765"),
"CLI serve help must specify 127.0.0.1:8765 as the default listen address. Output:\n{}",
stdout
);
}
#[test]
fn test_default_address_is_loopback_socket() {
let default_addr: std::net::SocketAddr = "127.0.0.1:8765".parse().unwrap();
assert!(
default_addr.ip().is_loopback(),
"Default address must be loopback to avoid LAN exposure"
);
}
#[test]
fn test_cli_hub_url_env_fallback() {
let bin_path = std::env::var("CARGO_BIN_EXE_ilink-hub")
.unwrap_or_else(|_| "./target/release/ilink-hub".to_string());
let output = std::process::Command::new(&bin_path)
.arg("register")
.arg("--help")
.env("WEIXIN_BASE_URL", "http://127.0.0.1:9001")
.env_remove("ILINK_HUB_URL")
.env_remove("ILINK_HUB_ADDR")
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("http://127.0.0.1:9001"),
"Should use WEIXIN_BASE_URL: {}",
stdout
);
let output = std::process::Command::new(&bin_path)
.arg("register")
.arg("--help")
.env_remove("WEIXIN_BASE_URL")
.env("ILINK_HUB_URL", "http://127.0.0.1:9002")
.env_remove("ILINK_HUB_ADDR")
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("http://127.0.0.1:9002"),
"Should use ILINK_HUB_URL: {}",
stdout
);
let output = std::process::Command::new(&bin_path)
.arg("register")
.arg("--help")
.env_remove("WEIXIN_BASE_URL")
.env_remove("ILINK_HUB_URL")
.env("ILINK_HUB_ADDR", "http://127.0.0.1:9003")
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("http://127.0.0.1:9003"),
"Should use ILINK_HUB_ADDR: {}",
stdout
);
}
#[test]
fn test_bridge_hub_url_env_fallback() {
let bin_path = std::env::var("CARGO_BIN_EXE_ilink-hub-bridge")
.unwrap_or_else(|_| "./target/release/ilink-hub-bridge".to_string());
let output = std::process::Command::new(&bin_path)
.arg("--help")
.env("WEIXIN_BASE_URL", "http://127.0.0.1:9001")
.env_remove("ILINK_HUB_URL")
.env_remove("ILINK_HUB_ADDR")
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("http://127.0.0.1:9001"),
"Should use WEIXIN_BASE_URL: {}",
stdout
);
let output = std::process::Command::new(&bin_path)
.arg("--help")
.env_remove("WEIXIN_BASE_URL")
.env("ILINK_HUB_URL", "http://127.0.0.1:9002")
.env_remove("ILINK_HUB_ADDR")
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("http://127.0.0.1:9002"),
"Should use ILINK_HUB_URL: {}",
stdout
);
let output = std::process::Command::new(&bin_path)
.arg("--help")
.env_remove("WEIXIN_BASE_URL")
.env_remove("ILINK_HUB_URL")
.env("ILINK_HUB_ADDR", "http://127.0.0.1:9003")
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("http://127.0.0.1:9003"),
"Should use ILINK_HUB_ADDR: {}",
stdout
);
}
#[test]
fn test_cli_deprecation_warnings() {
let bin_path = std::env::var("CARGO_BIN_EXE_ilink-hub")
.unwrap_or_else(|_| "./target/release/ilink-hub".to_string());
let output = std::process::Command::new(&bin_path)
.arg("register")
.arg("--help")
.env("RUST_LOG", "debug")
.env_remove("WEIXIN_BASE_URL")
.env("ILINK_HUB_URL", "http://127.0.0.1:9002")
.env_remove("ILINK_HUB_ADDR")
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let combined = format!("{}\n{}", stdout, stderr);
assert!(
combined.contains("are deprecated") && combined.contains("ILINK_HUB_URL"),
"Should print deprecation warning for ILINK_HUB_URL, got: {}",
combined
);
let output = std::process::Command::new(&bin_path)
.arg("register")
.arg("--help")
.env("RUST_LOG", "debug")
.env("WEIXIN_BASE_URL", "http://127.0.0.1:9001")
.env("ILINK_HUB_URL", "http://127.0.0.1:9002")
.env_remove("ILINK_HUB_ADDR")
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let combined = format!("{}\n{}", stdout, stderr);
assert!(
!combined.contains("are deprecated"),
"Should NOT print deprecation warning if WEIXIN_BASE_URL is set, got: {}",
combined
);
}
#[test]
fn test_bridge_deprecation_warnings() {
let bin_path = std::env::var("CARGO_BIN_EXE_ilink-hub-bridge")
.unwrap_or_else(|_| "./target/release/ilink-hub-bridge".to_string());
let output = std::process::Command::new(&bin_path)
.arg("--help")
.env("RUST_LOG", "info")
.env_remove("WEIXIN_BASE_URL")
.env_remove("ILINK_HUB_URL")
.env("ILINK_HUB_ADDR", "127.0.0.1:9003")
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let combined = format!("{}\n{}", stdout, stderr);
assert!(
combined.contains("are deprecated") && combined.contains("ILINK_HUB_ADDR"),
"Bridge should print deprecation warning for ILINK_HUB_ADDR, got: {}",
combined
);
}
#[test]
fn test_bind_failure_friendly_message() {
let bin_path = std::env::var("CARGO_BIN_EXE_ilink-hub")
.unwrap_or_else(|_| "./target/release/ilink-hub".to_string());
let output = std::process::Command::new(&bin_path)
.arg("serve")
.env("RUST_LOG", "info")
.env("WEIXIN_BASE_URL", "http://254.254.254.254:8765")
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("Failed to bind to address") && stderr.contains("WEIXIN_BASE_URL"),
"Should output a friendly suggestion when EADDRNOTAVAIL occurs, got: {}",
stderr
);
}
#[tokio::test]
async fn test_body_limit_global() {
let state = make_state().await;
let app = build_router(state);
let limit = 256 * 1024;
let body_ok = "a".repeat(limit);
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/ilink/bot/sendtyping")
.header("Content-Type", "application/json")
.body(Body::from(body_ok))
.unwrap(),
)
.await
.unwrap();
assert_ne!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE);
let body_too_large = "a".repeat(limit + 1);
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/ilink/bot/sendtyping")
.header("Content-Type", "application/json")
.body(Body::from(body_too_large))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE);
}
#[tokio::test]
async fn test_body_limit_sendmessage_override() {
let state = make_state().await;
let app = build_router(state);
let body_ok = "a".repeat(256 * 1024 + 1);
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/ilink/bot/sendmessage")
.header("Content-Type", "application/json")
.body(Body::from(body_ok))
.unwrap(),
)
.await
.unwrap();
assert_ne!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE);
let limit_4mb = 4 * 1024 * 1024;
let body_ok_4mb = "a".repeat(limit_4mb);
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/ilink/bot/sendmessage")
.header("Content-Type", "application/json")
.body(Body::from(body_ok_4mb))
.unwrap(),
)
.await
.unwrap();
assert_ne!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE);
let body_too_large_4mb = "a".repeat(limit_4mb + 1);
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/ilink/bot/sendmessage")
.header("Content-Type", "application/json")
.body(Body::from(body_too_large_4mb))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE);
}
#[tokio::test]
async fn metrics_requires_auth_when_no_token_configured() {
if std::env::var("ILINK_ADMIN_TOKEN").is_ok() {
return;
}
if std::env::var("ILINK_ADMIN_INSECURE_NO_AUTH")
.ok()
.map(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes"))
.unwrap_or(false)
{
return;
}
let state = make_state().await;
let app = build_router(state);
let resp = app
.oneshot(
Request::builder()
.method("GET")
.uri("/metrics")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}