59fb56558f
Add the static HTTP dashboard example and wire in the recent daemon/API polish: CORS-aware API routing, service-install behavior cleanup, safer systemd unit ExecStart quoting, and friendly-name validation for path-safe targeting. Also refresh README/API/roadmap docs, remove the temporary claude observations file, and include the related tests for API/status and daemon validation.
452 lines
15 KiB
Rust
452 lines
15 KiB
Rust
use std::{
|
|
net::TcpListener,
|
|
path::PathBuf,
|
|
process::{Child, Command, Stdio},
|
|
sync::Arc,
|
|
time::Duration,
|
|
};
|
|
|
|
use chrono::Utc;
|
|
use reqwest::Client;
|
|
use serde_json::Value;
|
|
use tempfile::TempDir;
|
|
use tokio::{net::TcpListener as TokioTcpListener, sync::Mutex, task::JoinHandle, time::sleep};
|
|
use tvctl::{
|
|
adapters::Device,
|
|
daemon::{
|
|
Daemon, INTERNAL_DAEMON_ENV, SharedDaemon,
|
|
cache::AppCacheStore,
|
|
config::{DaemonConfig, DevConfig, DiscoveryConfig, RuntimePaths, TvctlConfig},
|
|
discovery::DiscoveryService,
|
|
registry::{AdapterRegistry, DeviceRegistry},
|
|
state::StateCache,
|
|
},
|
|
};
|
|
use uuid::Uuid;
|
|
|
|
struct TestDaemon {
|
|
_temp_dir: TempDir,
|
|
child: Child,
|
|
base_url: String,
|
|
device: Device,
|
|
}
|
|
|
|
struct InProcessApi {
|
|
_temp_dir: TempDir,
|
|
server: JoinHandle<()>,
|
|
base_url: String,
|
|
device: Device,
|
|
socket_path: PathBuf,
|
|
}
|
|
|
|
impl TestDaemon {
|
|
async fn start() -> Self {
|
|
let temp_dir = tempfile::tempdir().expect("temp dir should exist");
|
|
let root = temp_dir.path();
|
|
let home = root.join("home");
|
|
let config_home = root.join("config");
|
|
let data_home = root.join("data");
|
|
let runtime_dir = root.join("runtime");
|
|
std::fs::create_dir_all(&home).expect("home dir should exist");
|
|
std::fs::create_dir_all(&config_home).expect("config dir should exist");
|
|
std::fs::create_dir_all(&data_home).expect("data dir should exist");
|
|
std::fs::create_dir_all(&runtime_dir).expect("runtime dir should exist");
|
|
|
|
let port = pick_unused_port();
|
|
let config = TvctlConfig {
|
|
daemon: tvctl::daemon::config::DaemonConfig {
|
|
socket: runtime_dir.join("tvctl.sock").display().to_string(),
|
|
http_enabled: true,
|
|
http_port: port,
|
|
http_host: "127.0.0.1".to_string(),
|
|
cors_enabled: false,
|
|
cors_allowed_origins: Vec::new(),
|
|
log_level: "info".to_string(),
|
|
},
|
|
discovery: tvctl::daemon::config::DiscoveryConfig {
|
|
auto_discover: false,
|
|
interval_secs: 300,
|
|
timeout_secs: 1,
|
|
},
|
|
dev: tvctl::daemon::config::DevConfig {
|
|
enabled: true,
|
|
roku_username: "rokudev".to_string(),
|
|
roku_password: "secret".to_string(),
|
|
},
|
|
..TvctlConfig::default()
|
|
};
|
|
|
|
let config_path = config_home.join("tvctl/config.toml");
|
|
let data_dir = data_home.join("tvctl");
|
|
|
|
config
|
|
.save_to_path(&config_path)
|
|
.await
|
|
.expect("config should save");
|
|
|
|
let device = Device {
|
|
id: Uuid::new_v4(),
|
|
name: "API Test Roku".to_string(),
|
|
original_name: "API Test Roku".to_string(),
|
|
platform: "roku".to_string(),
|
|
address: "127.0.0.2".parse().expect("loopback should parse"),
|
|
port: 8060,
|
|
is_default: true,
|
|
discovered_at: Utc::now(),
|
|
last_seen: Utc::now(),
|
|
};
|
|
std::fs::create_dir_all(&data_dir).expect("data dir should exist");
|
|
std::fs::write(
|
|
data_dir.join("devices.json"),
|
|
serde_json::to_vec_pretty(&vec![device.clone()]).expect("device should encode"),
|
|
)
|
|
.expect("devices file should write");
|
|
|
|
let binary = std::env::var("CARGO_BIN_EXE_tvctl").expect("binary path should exist");
|
|
let child = Command::new(binary)
|
|
.env(INTERNAL_DAEMON_ENV, "1")
|
|
.env("HOME", &home)
|
|
.env("XDG_CONFIG_HOME", &config_home)
|
|
.env("XDG_DATA_HOME", &data_home)
|
|
.env("XDG_RUNTIME_DIR", &runtime_dir)
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null())
|
|
.spawn()
|
|
.expect("daemon should start");
|
|
|
|
let instance = Self {
|
|
_temp_dir: temp_dir,
|
|
child,
|
|
base_url: format!("http://127.0.0.1:{port}/v1"),
|
|
device,
|
|
};
|
|
instance.wait_until_ready().await;
|
|
instance
|
|
}
|
|
|
|
async fn wait_until_ready(&self) {
|
|
let client = Client::new();
|
|
for _ in 0..40 {
|
|
if let Ok(response) = client
|
|
.get(format!("{}/daemon/status", self.base_url))
|
|
.send()
|
|
.await
|
|
&& response.status().is_success()
|
|
{
|
|
return;
|
|
}
|
|
sleep(Duration::from_millis(100)).await;
|
|
}
|
|
panic!("daemon HTTP API did not become ready");
|
|
}
|
|
|
|
fn shutdown(&mut self) {
|
|
let _ = self.child.kill();
|
|
let _ = self.child.wait();
|
|
}
|
|
}
|
|
|
|
impl Drop for TestDaemon {
|
|
fn drop(&mut self) {
|
|
self.shutdown();
|
|
}
|
|
}
|
|
|
|
impl InProcessApi {
|
|
async fn start() -> Self {
|
|
Self::start_with_daemon_config(DaemonConfig::default()).await
|
|
}
|
|
|
|
async fn start_with_daemon_config(daemon_config: DaemonConfig) -> Self {
|
|
let temp_dir = tempfile::tempdir().expect("temp dir should exist");
|
|
let root = temp_dir.path();
|
|
let config_home = root.join("config");
|
|
let data_home = root.join("data");
|
|
let runtime_dir = root.join("runtime");
|
|
std::fs::create_dir_all(&config_home).expect("config dir should exist");
|
|
std::fs::create_dir_all(&data_home).expect("data dir should exist");
|
|
std::fs::create_dir_all(&runtime_dir).expect("runtime dir should exist");
|
|
|
|
let listener = TokioTcpListener::bind("127.0.0.1:0")
|
|
.await
|
|
.expect("ephemeral http port should bind");
|
|
let port = listener
|
|
.local_addr()
|
|
.expect("listener addr should exist")
|
|
.port();
|
|
let socket_path = runtime_dir.join("tvctl.sock");
|
|
let config = TvctlConfig {
|
|
daemon: DaemonConfig {
|
|
socket: socket_path.display().to_string(),
|
|
http_enabled: true,
|
|
http_port: port,
|
|
http_host: "127.0.0.1".to_string(),
|
|
cors_enabled: daemon_config.cors_enabled,
|
|
cors_allowed_origins: daemon_config.cors_allowed_origins,
|
|
log_level: daemon_config.log_level,
|
|
},
|
|
discovery: DiscoveryConfig {
|
|
auto_discover: false,
|
|
interval_secs: 300,
|
|
timeout_secs: 1,
|
|
},
|
|
dev: DevConfig {
|
|
enabled: true,
|
|
roku_username: "rokudev".to_string(),
|
|
roku_password: "secret".to_string(),
|
|
},
|
|
..TvctlConfig::default()
|
|
};
|
|
|
|
let config_path = config_home.join("tvctl/config.toml");
|
|
let data_dir = data_home.join("tvctl");
|
|
config
|
|
.save_to_path(&config_path)
|
|
.await
|
|
.expect("config should save");
|
|
|
|
let device = Device {
|
|
id: Uuid::new_v4(),
|
|
name: "In Process Roku".to_string(),
|
|
original_name: "In Process Roku".to_string(),
|
|
platform: "roku".to_string(),
|
|
address: "127.0.0.3".parse().expect("loopback should parse"),
|
|
port: 8060,
|
|
is_default: true,
|
|
discovered_at: Utc::now(),
|
|
last_seen: Utc::now(),
|
|
};
|
|
std::fs::create_dir_all(&data_dir).expect("data dir should exist");
|
|
std::fs::write(
|
|
data_dir.join("devices.json"),
|
|
serde_json::to_vec_pretty(&vec![device.clone()]).expect("device should encode"),
|
|
)
|
|
.expect("devices file should write");
|
|
|
|
let paths = RuntimePaths {
|
|
config_file: config_path,
|
|
data_dir: data_dir.clone(),
|
|
devices_file: data_dir.join("devices.json"),
|
|
cache_dir: data_dir.join("cache"),
|
|
active_socket_file: data_dir.join("active_socket"),
|
|
socket_file: socket_path.clone(),
|
|
};
|
|
let adapters = AdapterRegistry::from_config(&config);
|
|
let mut registry = DeviceRegistry::load(paths.devices_file.clone())
|
|
.await
|
|
.expect("registry should load");
|
|
if !config.devices.default.is_empty() {
|
|
let _ = registry.set_default(&config.devices.default);
|
|
} else {
|
|
registry.ensure_default();
|
|
}
|
|
let daemon_http_config = config.daemon.clone();
|
|
let daemon: SharedDaemon = Arc::new(Mutex::new(Daemon {
|
|
config,
|
|
paths: paths.clone(),
|
|
registry,
|
|
app_cache: AppCacheStore::new(paths.cache_dir.clone()),
|
|
state_cache: StateCache::default(),
|
|
adapters: adapters.clone(),
|
|
discovery: DiscoveryService::new(adapters),
|
|
}));
|
|
|
|
let app = tvctl::api::router_with_config(daemon, &daemon_http_config);
|
|
let server = tokio::spawn(async move {
|
|
axum::serve(listener, app)
|
|
.await
|
|
.expect("in-process api should serve");
|
|
});
|
|
|
|
let instance = Self {
|
|
_temp_dir: temp_dir,
|
|
server,
|
|
base_url: format!("http://127.0.0.1:{port}/v1"),
|
|
device,
|
|
socket_path,
|
|
};
|
|
instance.wait_until_ready().await;
|
|
instance
|
|
}
|
|
|
|
async fn wait_until_ready(&self) {
|
|
let client = Client::new();
|
|
for _ in 0..40 {
|
|
if let Ok(response) = client
|
|
.get(format!("{}/daemon/status", self.base_url))
|
|
.send()
|
|
.await
|
|
&& response.status().is_success()
|
|
{
|
|
return;
|
|
}
|
|
sleep(Duration::from_millis(100)).await;
|
|
}
|
|
panic!("in-process HTTP API did not become ready");
|
|
}
|
|
}
|
|
|
|
impl Drop for InProcessApi {
|
|
fn drop(&mut self) {
|
|
self.server.abort();
|
|
}
|
|
}
|
|
|
|
fn pick_unused_port() -> u16 {
|
|
let listener = TcpListener::bind("127.0.0.1:0").expect("ephemeral port should bind");
|
|
let port = listener
|
|
.local_addr()
|
|
.expect("local addr should exist")
|
|
.port();
|
|
drop(listener);
|
|
port
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn http_api_exposes_core_routes_and_config_patch() {
|
|
let daemon = TestDaemon::start().await;
|
|
let client = Client::new();
|
|
|
|
let status = client
|
|
.get(format!("{}/daemon/status", daemon.base_url))
|
|
.send()
|
|
.await
|
|
.expect("status should respond");
|
|
assert!(status.status().is_success());
|
|
let status_json: Value = status.json().await.expect("status json should parse");
|
|
assert_eq!(status_json["ok"], true);
|
|
assert_eq!(status_json["data"]["device_count"], 1);
|
|
|
|
let devices = client
|
|
.get(format!("{}/devices", daemon.base_url))
|
|
.send()
|
|
.await
|
|
.expect("devices should respond");
|
|
assert!(devices.status().is_success());
|
|
let devices_json: Value = devices.json().await.expect("devices json should parse");
|
|
assert_eq!(devices_json["data"][0]["name"], daemon.device.name);
|
|
|
|
let device = client
|
|
.get(format!("{}/devices/{}", daemon.base_url, daemon.device.id))
|
|
.send()
|
|
.await
|
|
.expect("device should respond");
|
|
assert!(device.status().is_success());
|
|
let device_json: Value = device.json().await.expect("device json should parse");
|
|
assert_eq!(device_json["data"]["id"], daemon.device.id.to_string());
|
|
|
|
let invalid_key = client
|
|
.post(format!(
|
|
"{}/devices/{}/remote/key",
|
|
daemon.base_url, daemon.device.id
|
|
))
|
|
.json(&serde_json::json!({ "key": "definitely-not-a-key" }))
|
|
.send()
|
|
.await
|
|
.expect("invalid key request should respond");
|
|
assert_eq!(invalid_key.status(), 400);
|
|
let invalid_key_json: Value = invalid_key
|
|
.json()
|
|
.await
|
|
.expect("invalid key json should parse");
|
|
assert_eq!(invalid_key_json["error"]["code"], "invalid_key");
|
|
|
|
let patch = client
|
|
.patch(format!("{}/config", daemon.base_url))
|
|
.json(&serde_json::json!({
|
|
"daemon.log_level": "debug",
|
|
"remote.roku_press_duration_ms": 90
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("config patch should respond");
|
|
assert!(patch.status().is_success());
|
|
let patch_json: Value = patch.json().await.expect("patch json should parse");
|
|
assert_eq!(patch_json["ok"], true);
|
|
|
|
let config = client
|
|
.get(format!("{}/config", daemon.base_url))
|
|
.send()
|
|
.await
|
|
.expect("config should respond");
|
|
assert!(config.status().is_success());
|
|
let config_json: Value = config.json().await.expect("config json should parse");
|
|
assert_eq!(config_json["data"]["daemon"]["log_level"], "debug");
|
|
assert_eq!(config_json["data"]["remote"]["roku_press_duration_ms"], 90);
|
|
assert_eq!(config_json["data"]["dev"]["roku_password"], "<redacted>");
|
|
|
|
let reload = client
|
|
.post(format!("{}/config/reload", daemon.base_url))
|
|
.send()
|
|
.await
|
|
.expect("reload should respond");
|
|
assert!(reload.status().is_success());
|
|
let reload_json: Value = reload.json().await.expect("reload json should parse");
|
|
assert_eq!(reload_json["ok"], true);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn http_api_routes_requests_without_unix_socket_loopback() {
|
|
let api = InProcessApi::start().await;
|
|
assert!(
|
|
!api.socket_path.exists(),
|
|
"test should not start a daemon unix socket listener"
|
|
);
|
|
|
|
let client = Client::new();
|
|
let devices = client
|
|
.get(format!("{}/devices", api.base_url))
|
|
.send()
|
|
.await
|
|
.expect("devices should respond");
|
|
assert!(devices.status().is_success());
|
|
|
|
let devices_json: Value = devices.json().await.expect("devices json should parse");
|
|
assert_eq!(devices_json["ok"], true);
|
|
assert_eq!(devices_json["data"][0]["id"], api.device.id.to_string());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn http_api_cors_is_disabled_by_default() {
|
|
let api = InProcessApi::start().await;
|
|
let client = Client::new();
|
|
let response = client
|
|
.get(format!("{}/devices", api.base_url))
|
|
.header("Origin", "http://127.0.0.1:8080")
|
|
.send()
|
|
.await
|
|
.expect("cors default response should arrive");
|
|
assert!(
|
|
response
|
|
.headers()
|
|
.get("access-control-allow-origin")
|
|
.is_none(),
|
|
"CORS headers should be absent by default"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn http_api_cors_allows_configured_loopback_origin() {
|
|
let api = InProcessApi::start_with_daemon_config(DaemonConfig {
|
|
cors_enabled: true,
|
|
cors_allowed_origins: vec!["http://127.0.0.1:8080".to_string()],
|
|
..DaemonConfig::default()
|
|
})
|
|
.await;
|
|
let client = Client::new();
|
|
let response = client
|
|
.get(format!("{}/devices", api.base_url))
|
|
.header("Origin", "http://127.0.0.1:8080")
|
|
.send()
|
|
.await
|
|
.expect("cors allowed response should arrive");
|
|
assert_eq!(
|
|
response
|
|
.headers()
|
|
.get("access-control-allow-origin")
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("http://127.0.0.1:8080")
|
|
);
|
|
}
|