diff options
| author | Aram Drevekenin <aram@poor.dev> | 2025-10-06 16:25:53 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-10-06 16:25:53 +0200 |
| commit | 209d525c92b0354d6fce87f14513dfc2b12a81c3 (patch) | |
| tree | 5afeb836dd1aa96dc93cb857a7f8286f6fcce15e /zellij-client/src/remote_attach/unit | |
| parent | fix(resurrection): allow splitting fixed one-row fixed panes (#4442) (diff) | |
| download | sl0ppij-209d525c92b0354d6fce87f14513dfc2b12a81c3.tar.gz sl0ppij-209d525c92b0354d6fce87f14513dfc2b12a81c3.tar.xz sl0ppij-209d525c92b0354d6fce87f14513dfc2b12a81c3.zip | |
New Feature: remote attach to a Zellij session from the terminal (#4460)
* remote web attach
* docs(changelog): add PR
Diffstat (limited to 'zellij-client/src/remote_attach/unit')
| -rw-r--r-- | zellij-client/src/remote_attach/unit/mod.rs | 1 | ||||
| -rw-r--r-- | zellij-client/src/remote_attach/unit/remote_attach_tests.rs | 675 |
2 files changed, 676 insertions, 0 deletions
diff --git a/zellij-client/src/remote_attach/unit/mod.rs b/zellij-client/src/remote_attach/unit/mod.rs new file mode 100644 index 00000000..1f2fbe84 --- /dev/null +++ b/zellij-client/src/remote_attach/unit/mod.rs @@ -0,0 +1 @@ +mod remote_attach_tests; diff --git a/zellij-client/src/remote_attach/unit/remote_attach_tests.rs b/zellij-client/src/remote_attach/unit/remote_attach_tests.rs new file mode 100644 index 00000000..6a6e0cd5 --- /dev/null +++ b/zellij-client/src/remote_attach/unit/remote_attach_tests.rs @@ -0,0 +1,675 @@ +use super::super::*; +use crate::RemoteClientError; +use serial_test::serial; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::time::Duration; +use zellij_utils::remote_session_tokens; + +// Mock server infrastructure +#[cfg(feature = "web_server_capability")] +mod mock_server { + use super::*; + use axum::{ + extract::State, + http::StatusCode, + response::Response, + routing::{get, post}, + Json, Router, + }; + use axum_extra::extract::cookie::{Cookie, CookieJar}; + use serde::Deserialize; + use serde_json::json; + use tokio::net::TcpListener; + use uuid::Uuid; + + #[derive(Clone)] + pub struct MockRemoteServerState { + pub valid_auth_tokens: Arc<Mutex<HashMap<String, ()>>>, + pub session_tokens: Arc<Mutex<HashMap<String, String>>>, // token -> web_client_id + pub endpoints_called: Arc<Mutex<Vec<String>>>, + } + + impl MockRemoteServerState { + pub fn new() -> Self { + Self { + valid_auth_tokens: Arc::new(Mutex::new(HashMap::new())), + session_tokens: Arc::new(Mutex::new(HashMap::new())), + endpoints_called: Arc::new(Mutex::new(Vec::new())), + } + } + + pub fn add_valid_token(&self, token: &str) { + self.valid_auth_tokens + .lock() + .unwrap() + .insert(token.to_string(), ()); + } + + fn record_endpoint(&self, endpoint: &str) { + self.endpoints_called + .lock() + .unwrap() + .push(endpoint.to_string()); + } + + pub fn get_endpoints_called(&self) -> Vec<String> { + self.endpoints_called.lock().unwrap().clone() + } + } + + #[derive(Deserialize)] + struct LoginRequest { + auth_token: String, + } + + async fn handle_login( + State(state): State<MockRemoteServerState>, + jar: CookieJar, + Json(payload): Json<LoginRequest>, + ) -> Result<(CookieJar, Json<serde_json::Value>), StatusCode> { + state.record_endpoint("/command/login"); + + let valid_tokens = state.valid_auth_tokens.lock().unwrap(); + if !valid_tokens.contains_key(&payload.auth_token) { + return Err(StatusCode::UNAUTHORIZED); + } + drop(valid_tokens); + + // Always create a session token (cookie is always set) + let session_token = Uuid::new_v4().to_string(); + let web_client_id = Uuid::new_v4().to_string(); + + state + .session_tokens + .lock() + .unwrap() + .insert(session_token.clone(), web_client_id); + + let cookie = Cookie::build(("session_token", session_token)) + .path("/") + .http_only(true) + .build(); + let jar = jar.add(cookie); + + Ok(( + jar, + Json(json!({ + "success": true, + "message": "Login successful" + })), + )) + } + + async fn handle_session( + State(state): State<MockRemoteServerState>, + jar: CookieJar, + ) -> Result<Json<serde_json::Value>, StatusCode> { + state.record_endpoint("/session"); + + let session_token = jar + .get("session_token") + .map(|c| c.value().to_string()) + .ok_or(StatusCode::UNAUTHORIZED)?; + + let session_tokens = state.session_tokens.lock().unwrap(); + let web_client_id = session_tokens + .get(&session_token) + .ok_or(StatusCode::UNAUTHORIZED)? + .clone(); + drop(session_tokens); + + Ok(Json(json!({ + "web_client_id": web_client_id + }))) + } + + async fn handle_ws_terminal( + ws: axum::extract::ws::WebSocketUpgrade, + State(state): State<MockRemoteServerState>, + jar: CookieJar, + ) -> Result<Response, StatusCode> { + state.record_endpoint("/ws/terminal"); + + // Validate session token + let session_token = jar + .get("session_token") + .map(|c| c.value().to_string()) + .ok_or(StatusCode::UNAUTHORIZED)?; + + let session_tokens = state.session_tokens.lock().unwrap(); + if !session_tokens.contains_key(&session_token) { + return Err(StatusCode::UNAUTHORIZED); + } + drop(session_tokens); + + Ok(ws.on_upgrade(|socket| async move { + // Basic echo WebSocket handler + use axum::extract::ws::Message; + use futures_util::{SinkExt, StreamExt}; + let (mut sender, mut receiver) = socket.split(); + + while let Some(Ok(msg)) = receiver.next().await { + if let Message::Text(text) = msg { + let _ = sender.send(Message::Text(text)).await; + } + } + })) + } + + async fn handle_ws_control( + ws: axum::extract::ws::WebSocketUpgrade, + State(state): State<MockRemoteServerState>, + jar: CookieJar, + ) -> Result<Response, StatusCode> { + state.record_endpoint("/ws/control"); + + // Validate session token + let session_token = jar + .get("session_token") + .map(|c| c.value().to_string()) + .ok_or(StatusCode::UNAUTHORIZED)?; + + let session_tokens = state.session_tokens.lock().unwrap(); + if !session_tokens.contains_key(&session_token) { + return Err(StatusCode::UNAUTHORIZED); + } + drop(session_tokens); + + Ok(ws.on_upgrade(|socket| async move { + // Basic echo WebSocket handler + use axum::extract::ws::Message; + use futures_util::{SinkExt, StreamExt}; + let (mut sender, mut receiver) = socket.split(); + + while let Some(Ok(msg)) = receiver.next().await { + if let Message::Text(text) = msg { + let _ = sender.send(Message::Text(text)).await; + } + } + })) + } + + pub async fn start_mock_server( + state: MockRemoteServerState, + ) -> (u16, tokio::task::JoinHandle<()>) { + let app = Router::new() + .route("/command/login", post(handle_login)) + .route("/session", post(handle_session)) + .route("/ws/terminal", get(handle_ws_terminal)) + .route("/ws/terminal/{session_name}", get(handle_ws_terminal)) + .route("/ws/control", get(handle_ws_control)) + .with_state(state); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + + let server_handle = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + // Wait for server to be ready + tokio::time::sleep(Duration::from_millis(100)).await; + + (port, server_handle) + } +} + +// Database test helpers +fn setup_test_db(server_url: &str) { + let _ = remote_session_tokens::delete_session_token(server_url); +} + +fn cleanup_test_db(server_url: &str) { + let _ = remote_session_tokens::delete_session_token(server_url); +} + +// Mock ClientOsApi for testing +#[derive(Debug, Clone)] +struct MockClientOsApi; + +impl crate::os_input_output::ClientOsApi for MockClientOsApi { + fn get_terminal_size_using_fd(&self, _fd: i32) -> zellij_utils::pane_size::Size { + zellij_utils::pane_size::Size { rows: 24, cols: 80 } + } + + fn set_raw_mode(&mut self, _fd: i32) {} + + fn unset_raw_mode(&self, _fd: i32) -> Result<(), nix::Error> { + Ok(()) + } + + fn box_clone(&self) -> Box<dyn crate::os_input_output::ClientOsApi> { + Box::new(MockClientOsApi) + } + + fn read_from_stdin(&mut self) -> Result<Vec<u8>, &'static str> { + Ok(Vec::new()) + } + + fn get_stdin_reader(&self) -> Box<dyn std::io::BufRead> { + Box::new(std::io::BufReader::new(std::io::empty())) + } + + fn get_stdout_writer(&self) -> Box<dyn std::io::Write> { + Box::new(std::io::sink()) + } + + fn update_session_name(&mut self, _new_session_name: String) {} + + fn send_to_server(&self, _msg: zellij_utils::ipc::ClientToServerMsg) {} + + fn recv_from_server( + &self, + ) -> Option<( + zellij_utils::ipc::ServerToClientMsg, + zellij_utils::errors::ErrorContext, + )> { + None + } + + fn handle_signals(&self, _sigwinch_cb: Box<dyn Fn()>, _quit_cb: Box<dyn Fn()>) {} + + fn connect_to_server(&self, _path: &std::path::Path) {} + + fn load_palette(&self) -> zellij_utils::data::Palette { + zellij_utils::shared::default_palette() + } + + fn enable_mouse(&self) -> anyhow::Result<()> { + Ok(()) + } + + fn disable_mouse(&self) -> anyhow::Result<()> { + Ok(()) + } + + fn stdin_poller(&self) -> crate::os_input_output::StdinPoller { + crate::os_input_output::StdinPoller::default() + } +} + +// Tests +#[cfg(feature = "web_server_capability")] +mod tests { + use super::mock_server::*; + use super::*; + + // Helper function to call attach_to_remote_session from async context + async fn call_attach_to_remote_session( + remote_session_url: String, + token: Option<String>, + remember: bool, + forget: bool, + ) -> Result<WebSocketConnections, RemoteClientError> { + tokio::task::spawn_blocking(move || { + let runtime = tokio::runtime::Runtime::new().unwrap(); + let os_input: Box<dyn crate::os_input_output::ClientOsApi> = Box::new(MockClientOsApi); + attach_to_remote_session( + &runtime, + os_input, + &remote_session_url, + token, + remember, + forget, + ) + }) + .await + .unwrap() + } + + #[tokio::test] + #[serial] + async fn test_successful_authentication_with_valid_token() { + let server_state = MockRemoteServerState::new(); + let auth_token = "test-auth-token-123"; + server_state.add_valid_token(auth_token); + + let (port, server_handle) = start_mock_server(server_state.clone()).await; + let server_url = format!("http://127.0.0.1:{}/session-name", port); + + setup_test_db(&format!("http://127.0.0.1:{}", port)); + + let result = + call_attach_to_remote_session(server_url, Some(auth_token.to_string()), false, false) + .await; + + assert!( + result.is_ok(), + "Should successfully authenticate: {:?}", + result.err() + ); + + let endpoints = server_state.get_endpoints_called(); + assert!( + endpoints.contains(&"/command/login".to_string()), + "Should call login endpoint" + ); + assert!( + endpoints.contains(&"/session".to_string()), + "Should call session endpoint" + ); + assert!( + endpoints.contains(&"/ws/terminal".to_string()), + "Should establish terminal WebSocket" + ); + assert!( + endpoints.contains(&"/ws/control".to_string()), + "Should establish control WebSocket" + ); + + server_handle.abort(); + cleanup_test_db(&format!("http://127.0.0.1:{}", port)); + } + + #[tokio::test] + #[serial] + async fn test_failed_authentication_with_invalid_token() { + let server_state = MockRemoteServerState::new(); + // Don't add the token to valid tokens - server will reject it + + let (port, server_handle) = start_mock_server(server_state.clone()).await; + let server_url = format!("http://127.0.0.1:{}/session-name", port); + + setup_test_db(&format!("http://127.0.0.1:{}", port)); + + let result = call_attach_to_remote_session( + server_url, + Some("invalid-token".to_string()), + false, + false, + ) + .await; + + assert!(result.is_err(), "Should fail with invalid token"); + assert!( + matches!(result.unwrap_err(), RemoteClientError::InvalidAuthToken), + "Should return InvalidAuthToken error" + ); + + server_handle.abort(); + cleanup_test_db(&format!("http://127.0.0.1:{}", port)); + } + + #[tokio::test] + #[serial] + async fn test_save_session_token_with_remember_true() { + let server_state = MockRemoteServerState::new(); + let auth_token = "test-token-remember"; + server_state.add_valid_token(auth_token); + + let (port, server_handle) = start_mock_server(server_state.clone()).await; + let server_url = format!("http://127.0.0.1:{}/session-name", port); + let base_url = format!("http://127.0.0.1:{}", port); + + setup_test_db(&base_url); + + let result = call_attach_to_remote_session( + server_url, + Some(auth_token.to_string()), + true, // remember = true + false, + ) + .await; + + assert!(result.is_ok(), "Connection should succeed"); + + // Verify token was saved + let saved_token = remote_session_tokens::get_session_token(&base_url); + assert!(saved_token.is_ok()); + assert!( + saved_token.unwrap().is_some(), + "Session token should be saved" + ); + + server_handle.abort(); + cleanup_test_db(&base_url); + } + + #[tokio::test] + #[serial] + async fn test_dont_save_token_with_remember_false() { + let server_state = MockRemoteServerState::new(); + let auth_token = "test-token-no-remember"; + server_state.add_valid_token(auth_token); + + let (port, server_handle) = start_mock_server(server_state.clone()).await; + let server_url = format!("http://127.0.0.1:{}/session-name", port); + let base_url = format!("http://127.0.0.1:{}", port); + + setup_test_db(&base_url); + + let result = call_attach_to_remote_session( + server_url, + Some(auth_token.to_string()), + false, // remember = false + false, + ) + .await; + + assert!(result.is_ok(), "Connection should succeed"); + + // Verify token was NOT saved + let saved_token = remote_session_tokens::get_session_token(&base_url); + assert!(saved_token.is_ok()); + assert!( + saved_token.unwrap().is_none(), + "Session token should NOT be saved" + ); + + server_handle.abort(); + cleanup_test_db(&base_url); + } + + #[tokio::test] + #[serial] + async fn test_load_and_use_saved_session_token() { + let server_state = MockRemoteServerState::new(); + + // Pre-create a session token + let session_token = uuid::Uuid::new_v4().to_string(); + let web_client_id = uuid::Uuid::new_v4().to_string(); + server_state + .session_tokens + .lock() + .unwrap() + .insert(session_token.clone(), web_client_id); + + let (port, server_handle) = start_mock_server(server_state.clone()).await; + let server_url = format!("http://127.0.0.1:{}/session-name", port); + let base_url = format!("http://127.0.0.1:{}", port); + + setup_test_db(&base_url); + + // Save the session token + remote_session_tokens::save_session_token(&base_url, &session_token).unwrap(); + + let result = call_attach_to_remote_session( + server_url, None, // No auth token provided + false, false, + ) + .await; + + assert!(result.is_ok(), "Should successfully use saved token"); + + // Verify we did NOT call login endpoint (used saved token directly) + let endpoints = server_state.get_endpoints_called(); + assert!( + !endpoints.contains(&"/command/login".to_string()), + "Should NOT call login endpoint" + ); + assert!( + endpoints.contains(&"/session".to_string()), + "Should call session endpoint" + ); + + server_handle.abort(); + cleanup_test_db(&base_url); + } + + #[tokio::test] + #[serial] + async fn test_token_flag_deletes_saved_token() { + let server_state = MockRemoteServerState::new(); + let auth_token = "new-auth-token"; + server_state.add_valid_token(auth_token); + + let (port, server_handle) = start_mock_server(server_state.clone()).await; + let server_url = format!("http://127.0.0.1:{}/session-name", port); + let base_url = format!("http://127.0.0.1:{}", port); + + setup_test_db(&base_url); + + // Pre-save an old token + remote_session_tokens::save_session_token(&base_url, "old-token").unwrap(); + + let result = call_attach_to_remote_session( + server_url, + Some(auth_token.to_string()), // Providing new token + false, + false, + ) + .await; + + assert!(result.is_ok(), "Should succeed with new token"); + + // The old token should have been deleted before using new one + // (New token won't be saved because remember=false) + // Verify by checking that session endpoint was called (not using saved token) + let endpoints = server_state.get_endpoints_called(); + assert!( + endpoints.contains(&"/command/login".to_string()), + "Should use new auth token, not saved token" + ); + + server_handle.abort(); + cleanup_test_db(&base_url); + } + + #[tokio::test] + #[serial] + async fn test_successful_websocket_establishment() { + let server_state = MockRemoteServerState::new(); + let auth_token = "test-ws-token"; + server_state.add_valid_token(auth_token); + + let (port, server_handle) = start_mock_server(server_state.clone()).await; + let server_url = format!("http://127.0.0.1:{}/test-session", port); + let base_url = format!("http://127.0.0.1:{}", port); + + setup_test_db(&base_url); + + let result = + call_attach_to_remote_session(server_url, Some(auth_token.to_string()), false, false) + .await; + + assert!( + result.is_ok(), + "WebSocket connections should be established" + ); + + let connections = result.unwrap(); + assert!( + !connections.web_client_id.is_empty(), + "Should have web_client_id" + ); + + // Verify both WebSocket endpoints were called + let endpoints = server_state.get_endpoints_called(); + assert!( + endpoints.contains(&"/ws/terminal".to_string()), + "Terminal WebSocket should be established" + ); + assert!( + endpoints.contains(&"/ws/control".to_string()), + "Control WebSocket should be established" + ); + + server_handle.abort(); + cleanup_test_db(&base_url); + } + + #[tokio::test] + async fn test_url_parsing_for_session_name() { + // Test various URL formats + let test_cases = vec![ + ("https://example.com/my-session", "my-session"), + ("https://example.com/", ""), + ("https://example.com/path/to/session", "path/to/session"), + ("http://localhost:8080/test", "test"), + ]; + + for (url, expected_name) in test_cases { + let result = extract_session_name(url); + assert!(result.is_ok(), "Failed to parse URL: {}", url); + assert_eq!( + result.unwrap(), + expected_name, + "Wrong session name for URL: {}", + url + ); + } + } + + #[tokio::test] + async fn test_server_url_extraction() { + // Test various URL formats + let test_cases = vec![ + ( + "https://example.com:8080/session?foo=bar", + "https://example.com:8080", + ), + ("http://localhost/test", "http://localhost"), + ( + "https://example.com/path/to/session#anchor", + "https://example.com", + ), + ]; + + for (url, expected_base) in test_cases { + let result = extract_server_url(url); + assert!(result.is_ok(), "Failed to extract server URL: {}", url); + assert_eq!( + result.unwrap(), + expected_base, + "Wrong base URL for: {}", + url + ); + } + } + + #[tokio::test] + async fn test_invalid_url_format() { + let result = call_attach_to_remote_session( + "not-a-valid-url".to_string(), + Some("token".to_string()), + false, + false, + ) + .await; + + assert!(result.is_err(), "Should fail with malformed URL"); + assert!(matches!( + result.unwrap_err(), + RemoteClientError::UrlParseError(_) + )); + } +} + +// Tests that don't require the web_server_capability feature +#[cfg(not(feature = "web_server_capability"))] +mod tests { + use super::*; + + #[test] + fn test_url_parsing_without_server() { + // Basic URL parsing tests that don't require a server + let result = extract_session_name("https://example.com/my-session"); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "my-session"); + + let result = extract_server_url("https://example.com:8080/session?foo=bar"); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "https://example.com:8080"); + } +} |
