use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use nomifun_api_types::WebSocketMessage; use nomifun_office::OfficeError; use nomifun_office::proxy::{ProxyError, ProxyService}; use nomifun_office::types::DocType; use nomifun_office::watch_manager::{OfficecliWatchManager, ProcessHandle, ProcessSpawner}; use nomifun_realtime::EventBroadcaster; use tokio::io::AsyncWriteExt; use tokio::net::TcpListener; // --------------------------------------------------------------------------- // Test infrastructure // --------------------------------------------------------------------------- struct MockProcessHandle { alive: AtomicBool, } impl MockProcessHandle { fn new() -> Self { Self { alive: AtomicBool::new(true), } } } impl ProcessHandle for MockProcessHandle { fn kill(&self) { self.alive.store(false, Ordering::SeqCst); } fn is_alive(&self) -> bool { self.alive.load(Ordering::SeqCst) } } struct HttpMockSpawner { response_template: String, } #[async_trait::async_trait] impl ProcessSpawner for HttpMockSpawner { async fn spawn_officecli( &self, _file_path: &str, port: u16, _doc_type: DocType, ) -> Result, OfficeError> { let resp = self.response_template.replace("__PORT__", &port.to_string()); tokio::spawn(async move { let listener = TcpListener::bind(format!("127.0.0.1:{port}")).await.unwrap(); for _ in 0..10 { if let Ok((mut stream, _)) = listener.accept().await { let resp = resp.clone(); tokio::spawn(async move { let mut buf = vec![0u8; 4096]; let _ = tokio::io::AsyncReadExt::read(&mut stream, &mut buf).await; let _ = stream.write_all(resp.as_bytes()).await; let _ = stream.shutdown().await; }); } } }); Ok(Box::new(MockProcessHandle::new())) } async fn install_officecli(&self) -> Result<(), OfficeError> { Ok(()) } async fn is_officecli_installed(&self) -> bool { true } async fn check_update(&self, _doc_type: DocType) -> Result<(), OfficeError> { Ok(()) } } struct TcpOnlySpawner; #[async_trait::async_trait] impl ProcessSpawner for TcpOnlySpawner { async fn spawn_officecli( &self, _file_path: &str, port: u16, _doc_type: DocType, ) -> Result, OfficeError> { let listener = std::net::TcpListener::bind(format!("127.0.0.1:{port}")) .map_err(|e| OfficeError::StartFailed(e.to_string()))?; std::mem::forget(listener); Ok(Box::new(MockProcessHandle::new())) } async fn install_officecli(&self) -> Result<(), OfficeError> { Ok(()) } async fn is_officecli_installed(&self) -> bool { true } async fn check_update(&self, _doc_type: DocType) -> Result<(), OfficeError> { Ok(()) } } struct NoopBroadcaster; impl EventBroadcaster for NoopBroadcaster { fn broadcast(&self, _event: WebSocketMessage) {} } fn build_http_response(status: u16, headers: &[(&str, &str)], body: &str) -> String { let status_text = match status { 200 => "OK", 302 => "Found", 404 => "Not Found", _ => "Unknown", }; let mut resp = format!("HTTP/1.1 {status} {status_text}\r\n"); for (k, v) in headers { resp.push_str(&format!("{k}: {v}\r\n")); } if !headers.iter().any(|(k, _)| k.to_lowercase() == "content-length") { resp.push_str(&format!("Content-Length: {}\r\n", body.len())); } resp.push_str("\r\n"); resp.push_str(body); resp } async fn setup_proxy(doc_type: DocType, response_template: &str) -> (ProxyService, u16, tempfile::TempDir) { let spawner = HttpMockSpawner { response_template: response_template.to_owned(), }; let mgr = Arc::new(OfficecliWatchManager::new(Arc::new(spawner), Arc::new(NoopBroadcaster))); let dir = tempfile::tempdir().unwrap(); let file = dir.path().join("test.docx"); std::fs::write(&file, b"test").unwrap(); let port = mgr.start(file.to_str().unwrap(), doc_type).await.unwrap(); let proxy = ProxyService::new(mgr); (proxy, port, dir) } async fn setup_ssrf_proxy(doc_type: DocType) -> (ProxyService, u16, tempfile::TempDir) { let mgr = Arc::new(OfficecliWatchManager::new( Arc::new(TcpOnlySpawner), Arc::new(NoopBroadcaster), )); let dir = tempfile::tempdir().unwrap(); let file = dir.path().join("test.docx"); std::fs::write(&file, b"test").unwrap(); let port = mgr.start(file.to_str().unwrap(), doc_type).await.unwrap(); let proxy = ProxyService::new(mgr); (proxy, port, dir) } // --------------------------------------------------------------------------- // RP-2: PPT proxy SSRF protection — inactive port rejected // --------------------------------------------------------------------------- #[tokio::test] async fn rp2_ppt_proxy_ssrf_rejects_inactive_port() { let (proxy, _active_port, _dir) = setup_ssrf_proxy(DocType::Ppt).await; let result = proxy.forward(9999, "/index.html", DocType::Ppt, &[]).await; let err = result.unwrap_err(); assert!(matches!(err, ProxyError::PortNotActive(9999))); } // --------------------------------------------------------------------------- // RP-4: Office watch proxy SSRF protection — inactive port rejected // --------------------------------------------------------------------------- #[tokio::test] async fn rp4_office_watch_proxy_ssrf_rejects_inactive_port() { let (proxy, _active_port, _dir) = setup_ssrf_proxy(DocType::Word).await; let result = proxy.forward_watch(9999, "/", &[]).await; assert!(matches!(result.unwrap_err(), ProxyError::PortNotActive(9999))); } // --------------------------------------------------------------------------- // SSRF: wrong doc_type rejected even when port is active // --------------------------------------------------------------------------- #[tokio::test] async fn ssrf_wrong_doc_type_rejected() { let (proxy, active_port, _dir) = setup_ssrf_proxy(DocType::Word).await; let result = proxy.forward(active_port, "/index.html", DocType::Ppt, &[]).await; assert!(matches!(result.unwrap_err(), ProxyError::PortNotActive(_))); } // --------------------------------------------------------------------------- // H-1-13.8 fix: forward_watch accepts Excel session ports // --------------------------------------------------------------------------- #[tokio::test] async fn forward_watch_accepts_excel_session_port() { let response = build_http_response(200, &[("Content-Type", "text/plain")], "Excel preview"); let (proxy, port, _dir) = setup_proxy(DocType::Excel, &response).await; let result = proxy.forward_watch(port, "/", &[]).await.unwrap(); assert_eq!(result.status, 200); let body = String::from_utf8(result.body).unwrap(); assert!(body.contains("Excel preview")); } // --------------------------------------------------------------------------- // forward_watch accepts Word session ports // --------------------------------------------------------------------------- #[tokio::test] async fn forward_watch_accepts_word_session_port() { let response = build_http_response(200, &[("Content-Type", "text/plain")], "Word preview"); let (proxy, port, _dir) = setup_proxy(DocType::Word, &response).await; let result = proxy.forward_watch(port, "/", &[]).await.unwrap(); assert_eq!(result.status, 200); let body = String::from_utf8(result.body).unwrap(); assert!(body.contains("Word preview")); } // --------------------------------------------------------------------------- // forward_watch rejects PPT session ports // --------------------------------------------------------------------------- #[tokio::test] async fn forward_watch_rejects_ppt_session_port() { let (proxy, ppt_port, _dir) = setup_ssrf_proxy(DocType::Ppt).await; let result = proxy.forward_watch(ppt_port, "/", &[]).await; assert!(matches!(result.unwrap_err(), ProxyError::PortNotActive(_))); } // --------------------------------------------------------------------------- // RP-1 / RP-3: Proxy forwards plain text response // --------------------------------------------------------------------------- #[tokio::test] async fn rp1_rp3_proxy_forwards_plain_text() { let response = build_http_response(200, &[("Content-Type", "text/plain")], "Hello from preview"); let (proxy, port, _dir) = setup_proxy(DocType::Ppt, &response).await; let result = proxy.forward(port, "/index.html", DocType::Ppt, &[]).await.unwrap(); assert_eq!(result.status, 200); let body = String::from_utf8(result.body).unwrap(); assert!(body.contains("Hello from preview")); } // --------------------------------------------------------------------------- // RP-5: HTML injection — navigation guard script injected // --------------------------------------------------------------------------- #[tokio::test] async fn rp5_proxy_injects_navigation_guard_in_html() { let html_body = "PreviewContent"; let response = build_http_response(200, &[("Content-Type", "text/html")], html_body); let (proxy, port, _dir) = setup_proxy(DocType::Word, &response).await; let result = proxy.forward(port, "/", DocType::Word, &[]).await.unwrap(); assert_eq!(result.status, 200); let body = String::from_utf8(result.body).unwrap(); assert!(body.contains("